@hraness/dawg 0.4.0 → 0.5.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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
package/src/tui/menu.ts CHANGED
@@ -10,7 +10,16 @@
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 type { FaderSpec } from "./fader.ts";
17
+ import {
18
+ openingMeterCommand,
19
+ openingUnit,
20
+ tempoDetail,
21
+ tempoMenuNode,
22
+ } from "./menu-time.ts";
14
23
  import {
15
24
  AUTOMATION_PARAMETERS,
16
25
  automationPoints,
@@ -40,7 +49,22 @@ import {
40
49
  type NumberParam,
41
50
  type ParamSpec,
42
51
  } from "../../core/fx.ts";
43
- import { effectValues } from "../commands/fx.ts";
52
+ import {
53
+ effectValues,
54
+ parseEffectName,
55
+ parseParamName,
56
+ } from "../commands/fx.ts";
57
+ import {
58
+ LOUDNESS_TARGET_NAMES,
59
+ LOUDNESS_TARGETS,
60
+ MASTER_LIMITS,
61
+ MASTER_PRESETS,
62
+ MASTER_SPECS,
63
+ MASTER_UNITS,
64
+ describeMaster,
65
+ describeUnit,
66
+ type MasterUnit,
67
+ } from "../../core/master.ts";
44
68
  import { SAMPLE_CONTROLS, type SampleControl } from "../commands/sample.ts";
45
69
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
46
70
  import {
@@ -53,6 +77,7 @@ import { kitCatalog } from "../audio/kits.ts";
53
77
  import { SYNTH_KIT_NAMES } from "../../core/kits.ts";
54
78
  import { DRUM_PATTERNS } from "../../core/sdk/v1.ts";
55
79
  import { isDrumInstrument } from "../../core/drums.ts";
80
+ import { loopTicksOf } from "../../core/tempo.ts";
56
81
  import {
57
82
  SYNTH_GROUPS,
58
83
  SYNTH_PARAMS,
@@ -76,10 +101,19 @@ import {
76
101
  PERFORM_MODES,
77
102
  PROGRESSION_PRESETS,
78
103
  PROGRESSION_STYLES,
104
+ SCALE_NAMES,
79
105
  SPREADS,
80
106
  keyName,
81
107
  parseKey,
82
108
  } from "../../core/chords.ts";
109
+ import {
110
+ TUNING_LIMITS,
111
+ TUNING_MAPS,
112
+ TUNING_NAMES,
113
+ describeTuning,
114
+ type Tuning,
115
+ } from "../../core/tuning.ts";
116
+ import { rootName, tuningStepsText } from "../commands/tuning.ts";
83
117
  import {
84
118
  ARP_RATES,
85
119
  CHORD_MODES,
@@ -119,6 +153,13 @@ export type MenuAudition = Readonly<{
119
153
  hint: string;
120
154
  /** `♪ solo · B staged 2 · 42 ms`, shown in the title. */
121
155
  status?: string | undefined;
156
+ /**
157
+ * Which commands stage while auditioning (default `isStageable`); the
158
+ * chord settings screen also stages `/chords …` and `key …`.
159
+ */
160
+ stageable?: (command: string) => boolean;
161
+ /** The committed chord settings, for `staged ← committed` on Chords rows. */
162
+ committedChords?: ChordSettings;
122
163
  }>;
123
164
 
124
165
  type NumberField = Readonly<{
@@ -208,6 +249,8 @@ export type MenuResult =
208
249
  | { type: "unhover"; key: string }
209
250
  /** Chose a list item while the loop plays: stage it for good. */
210
251
  | { type: "choose"; command: string; key: string }
252
+ /** Enter on a number row: open the fader drawer on this level's values. */
253
+ | { type: "fader"; label: string }
211
254
  | { type: "pass" };
212
255
 
213
256
  export type MenuView = Readonly<{
@@ -382,10 +425,20 @@ export function rootNodes(context: MenuContext): MenuNode[] {
382
425
  kind: "menu",
383
426
  id: "project",
384
427
  label: "Project",
385
- detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
386
- help: "tempo, meter, loop length, play, click and count-in",
428
+ detail: context.score.time
429
+ ? `${tempoDetail(context.score)} · ${context.score.bars} bars`
430
+ : `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
431
+ help: "tempo, meter, tempo map, loop length, play, click and count-in",
387
432
  build: transportNodes,
388
433
  },
434
+ {
435
+ kind: "menu",
436
+ id: "arrange",
437
+ label: "Arrange",
438
+ detail: arrangeDetail(context),
439
+ help: "song sections, the form, builds, drops and fills",
440
+ build: arrangeNodes,
441
+ },
389
442
  ];
390
443
  }
391
444
 
@@ -394,20 +447,56 @@ const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
394
447
  sound: ["sound"],
395
448
  parameters: ["sound"],
396
449
  sounds: ["sound", "browse"],
450
+ performance: ["sound", "performance"],
451
+ expression: ["sound", "performance"],
397
452
  effects: ["effects"],
398
453
  rhythm: ["rhythm"],
399
454
  chords: ["chords"],
400
455
  mix: ["mix"],
401
456
  track: ["mix"],
402
457
  automation: ["mix", "automation"],
458
+ master: ["mix", "master"],
403
459
  project: ["project"],
404
460
  transport: ["project"],
461
+ tempo: ["project", "tempo"],
462
+ meter: ["project", "tempo"],
463
+ time: ["project", "tempo"],
464
+ tuning: ["project", "tuning"],
465
+ scale: ["project", "tuning"],
466
+ arrange: ["arrange"],
467
+ sections: ["arrange"],
468
+ form: ["arrange"],
405
469
  };
406
470
 
407
471
  /** Sound: instrument and voice first, then the sound browser. */
408
472
  function soundSectionNodes(context: MenuContext): MenuNode[] {
473
+ const track = focused(context);
474
+ const tuning: MenuNode[] =
475
+ track && !isDrumInstrument(track.instrument)
476
+ ? [
477
+ {
478
+ kind: "menu",
479
+ id: "track-tuning",
480
+ label: "tuning",
481
+ detail: track.tuning
482
+ ? tuningLabel(track.tuning)
483
+ : `song · ${tuningLabel(context.score.tuning)}`,
484
+ help: "this track's tuning, or follow the song",
485
+ build: trackTuningNodes,
486
+ },
487
+ ]
488
+ : [];
409
489
  return [
410
490
  ...parameterNodes(context),
491
+ ...tuning,
492
+ {
493
+ kind: "menu",
494
+ id: "performance",
495
+ label: "performance",
496
+ detail: performanceDetail(focused(context)),
497
+ help: "articulation, glide, bend, vibrato, pedal, velocity curve, humanize",
498
+ build: performanceNodes,
499
+ },
411
500
  {
412
501
  kind: "menu",
413
502
  id: "browse",
@@ -457,7 +546,184 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
457
546
  help: "points that move a value over the loop",
458
547
  build: automationNodes,
459
548
  },
549
+ {
550
+ kind: "menu",
551
+ id: "master",
552
+ label: "master",
553
+ detail: context.score.master
554
+ ? describeMaster(context.score.master)
555
+ : "off · bypass",
556
+ help: "the song master on the summed mix: EQ, glue, tape, width, limiter",
557
+ build: masterNodes,
558
+ },
559
+ ];
560
+ }
561
+
562
+ /** Mix & automation > Master: the loudness target, then each unit in order. */
563
+ function masterNodes(context: MenuContext): MenuNode[] {
564
+ const master = context.score.master;
565
+ // A name only when the LUFS and the limiter ceiling both match it, and no
566
+ // other name shares them (apple and podcast are both -16 at -1 dBTP).
567
+ const matches = LOUDNESS_TARGET_NAMES.filter(
568
+ (name) =>
569
+ LOUDNESS_TARGETS[name].lufs === master?.target &&
570
+ LOUDNESS_TARGETS[name].ceiling ===
571
+ (master?.limiter?.ceiling ?? MASTER_LIMITS.safeCeiling),
572
+ );
573
+ const named =
574
+ matches.length === 1
575
+ ? matches[0]
576
+ : matches.length > 1
577
+ ? `${num(master!.target!)} LUFS`
578
+ : undefined;
579
+ const nodes: MenuNode[] = [
580
+ {
581
+ kind: "choice",
582
+ label: "target",
583
+ value: master?.target === undefined ? "off" : (named ?? "custom"),
584
+ options: ["off", ...LOUDNESS_TARGET_NAMES],
585
+ command: (name) => `master target ${name}`,
586
+ help: "named loudness target; loud ones also load a fast limiter",
587
+ },
588
+ {
589
+ kind: "number",
590
+ label: "target LUFS",
591
+ value: master?.target,
592
+ start: -14,
593
+ off: "off",
594
+ min: MASTER_LIMITS.minTarget,
595
+ max: MASTER_LIMITS.maxTarget,
596
+ step: (value, direction) =>
597
+ clamp(
598
+ Math.round((value + direction * 0.5) * 2) / 2,
599
+ MASTER_LIMITS.minTarget,
600
+ MASTER_LIMITS.maxTarget,
601
+ ),
602
+ format: (value) => `${num(value)} LUFS`,
603
+ command: (value) => `master target ${num(value)}`,
604
+ reset: "master target off",
605
+ help: "integrated loudness renders normalize to (EBU R 128 / BS.1770)",
606
+ },
460
607
  ];
608
+ for (const unit of MASTER_UNITS) {
609
+ const spec = MASTER_SPECS[unit];
610
+ const values = master?.[unit];
611
+ nodes.push({
612
+ kind: "menu",
613
+ id: `master:${unit}`,
614
+ label: unit,
615
+ detail: values ? describeUnit(unit, values) : "off",
616
+ help: spec.doc,
617
+ build: (inner) => masterUnitNodes(inner, unit, false),
618
+ });
619
+ }
620
+ nodes.push({
621
+ kind: "action",
622
+ label: "measure the mix",
623
+ command: "master measure",
624
+ help: "render and report LUFS, true peak, LRA, balance and correlation",
625
+ });
626
+ if (master)
627
+ nodes.push({
628
+ kind: "action",
629
+ label: "remove master",
630
+ command: "master off",
631
+ help: "bypass: the mix renders exactly as without a master",
632
+ });
633
+ return nodes;
634
+ }
635
+
636
+ /** One master unit: on/off, preset, its simple params, then `advanced`. */
637
+ function masterUnitNodes(
638
+ context: MenuContext,
639
+ unit: MasterUnit,
640
+ advanced: boolean,
641
+ ): MenuNode[] {
642
+ const spec = MASTER_SPECS[unit];
643
+ const values = context.score.master?.[unit];
644
+ const nodes: MenuNode[] = [];
645
+ if (!advanced) {
646
+ nodes.push({
647
+ kind: "toggle",
648
+ label: "on",
649
+ value: values !== undefined,
650
+ command: (on) => `master ${unit} ${on ? "on" : "off"}`,
651
+ help: `switch the master ${spec.label} on or off`,
652
+ });
653
+ const presets = Object.keys(MASTER_PRESETS[unit]);
654
+ if (presets.length > 0)
655
+ nodes.push({
656
+ kind: "choice",
657
+ label: "preset",
658
+ value: "—",
659
+ options: presets,
660
+ command: (preset) => `master ${unit} preset ${preset}`,
661
+ help: "a starting point; every value stays editable",
662
+ });
663
+ }
664
+ const keys = advanced ? Object.keys(spec.params) : spec.simple;
665
+ for (const key of keys) {
666
+ const param = spec.params[key]!;
667
+ const current = values?.[key];
668
+ // With a target the search sets the limiter's drive; its gain is unused.
669
+ if (
670
+ unit === "limiter" &&
671
+ key === "gain" &&
672
+ context.score.master?.target !== undefined
673
+ ) {
674
+ nodes.push({ kind: "info", label: key, value: "set by target" });
675
+ continue;
676
+ }
677
+ if (param.kind === "number") {
678
+ nodes.push({
679
+ kind: "number",
680
+ label: key,
681
+ value: typeof current === "number" ? current : undefined,
682
+ start: param.default,
683
+ off: values
684
+ ? withUnit(formatParam(param, param.default), param.unit)
685
+ : "off",
686
+ min: param.min,
687
+ max: param.max,
688
+ step: specStep(param),
689
+ format: (value) => withUnit(formatParam(param, value), param.unit),
690
+ command: (value) =>
691
+ `master ${unit} ${key} ${formatParam(param, value)}`,
692
+ help: param.doc,
693
+ ...(values
694
+ ? {
695
+ reset: `master ${unit} ${key} ${formatParam(param, param.default)}`,
696
+ }
697
+ : {}),
698
+ });
699
+ } else if (param.kind === "enum") {
700
+ nodes.push({
701
+ kind: "choice",
702
+ label: key,
703
+ value: typeof current === "string" ? current : param.default,
704
+ options: param.values,
705
+ command: (option) => `master ${unit} ${key} ${option}`,
706
+ help: param.doc,
707
+ });
708
+ } else {
709
+ nodes.push({
710
+ kind: "toggle",
711
+ label: key,
712
+ value: typeof current === "boolean" ? current : param.default,
713
+ command: (on) => `master ${unit} ${key} ${on ? "on" : "off"}`,
714
+ help: param.doc,
715
+ });
716
+ }
717
+ }
718
+ if (!advanced && Object.keys(spec.params).length > spec.simple.length)
719
+ nodes.push({
720
+ kind: "menu",
721
+ id: `master:${unit}:advanced`,
722
+ label: "advanced",
723
+ detail: `all ${Object.keys(spec.params).length} params`,
724
+ build: (inner) => masterUnitNodes(inner, unit, true),
725
+ });
726
+ return nodes;
461
727
  }
462
728
 
463
729
  function chordsDetail(context: MenuContext): string {
@@ -471,7 +737,8 @@ function chordNodes(context: MenuContext): MenuNode[] {
471
737
  const chords = context.chords ?? defaultChordSettings();
472
738
  const key = parseKey(context.score.key ?? undefined);
473
739
  const tonic = key ? keyName(key).split(" ")[0]! : "C";
474
- const mode = key?.mode ?? "major";
740
+ // A library scale (D hijaz, C yaman) survives a tonic change.
741
+ const mode: string = key?.scale ?? key?.mode ?? "major";
475
742
  const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
476
743
  return [
477
744
  {
@@ -493,9 +760,9 @@ function chordNodes(context: MenuContext): MenuNode[] {
493
760
  {
494
761
  kind: "choice",
495
762
  label: "key mode",
496
- help: "major, minor or a church mode",
763
+ help: "major, minor, a church mode or any library scale",
497
764
  value: mode,
498
- options: MODE_NAMES,
765
+ options: [...MODE_NAMES, ...SCALE_NAMES],
499
766
  command: (option) => `key ${tonic} ${option}`,
500
767
  },
501
768
  {
@@ -1303,7 +1570,7 @@ function laneNodes(
1303
1570
  value: "two points",
1304
1571
  placeholder: "from-beat:value to-beat:value",
1305
1572
  command: (text) => pointsCommand(lane, text, 2),
1306
- example: `automate ${lane} points 0:${laneFormat(lane, min)} ${context.score.beatsPerBar * context.score.bars}:${laneFormat(lane, max)}`,
1573
+ example: `automate ${lane} points 0:${laneFormat(lane, min)} ${loopTicksOf(context.score) / context.score.ticksPerBeat}:${laneFormat(lane, max)}`,
1307
1574
  },
1308
1575
  ];
1309
1576
  for (const point of points)
@@ -1499,9 +1766,10 @@ function transportNodes(context: MenuContext): MenuNode[] {
1499
1766
  min: 1,
1500
1767
  max: SCORE_LIMITS.maxBeatsPerBar,
1501
1768
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
1502
- format: (value) => `${value}/4`,
1503
- command: (value) => `meter ${Math.round(value)}`,
1769
+ format: (value) => `${value}/${openingUnit(score)}`,
1770
+ command: (value) => openingMeterCommand(score, value),
1504
1771
  },
1772
+ tempoMenuNode(context),
1505
1773
  {
1506
1774
  kind: "number",
1507
1775
  label: "loop length",
@@ -1536,13 +1804,232 @@ function transportNodes(context: MenuContext): MenuNode[] {
1536
1804
  options: ["0", "1", "2"],
1537
1805
  command: (option) => `/count-in ${option}`,
1538
1806
  },
1807
+ {
1808
+ kind: "menu",
1809
+ id: "tuning",
1810
+ label: "tuning & scale",
1811
+ detail: `${tuningLabel(score.tuning)} · ${keyLabel(score.key)}`,
1812
+ help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
1813
+ build: songTuningNodes,
1814
+ },
1539
1815
  ];
1540
1816
  }
1541
1817
 
1818
+ // ── tuning ────────────────────────────────────────────────────────────
1819
+
1820
+ /** Short name of a tuning for a choice row: its library name or table. */
1821
+ function tuningLabel(tuning: Tuning | undefined): string {
1822
+ if (!tuning) return "12-tet";
1823
+ if (tuning.name) return tuning.name;
1824
+ if (tuning.edo) return `${tuning.edo}-edo`;
1825
+ if (tuning.scl) return tuning.scl.split("/").at(-1)!;
1826
+ if (tuning.ratios || tuning.cents) return "custom";
1827
+ return "12-tet";
1828
+ }
1829
+
1830
+ function keyLabel(key: string | null | undefined): string {
1831
+ const parsed = parseKey(key ?? undefined);
1832
+ return parsed ? keyName(parsed) : "no key";
1833
+ }
1834
+
1835
+ const ROOT_OPTIONS = [
1836
+ "auto",
1837
+ "C4",
1838
+ "Db4",
1839
+ "D4",
1840
+ "Eb4",
1841
+ "E4",
1842
+ "F4",
1843
+ "Gb4",
1844
+ "G4",
1845
+ "Ab4",
1846
+ "A4",
1847
+ "Bb4",
1848
+ "B4",
1849
+ ] as const;
1850
+
1851
+ /** Rows shared by the song and track tuning screens. */
1852
+ function tuningRows(
1853
+ tuning: Tuning | undefined,
1854
+ prefix: string,
1855
+ first: MenuNode,
1856
+ song?: Tuning,
1857
+ ): MenuNode[] {
1858
+ // A track screen shows the song's ref and root where the track has none.
1859
+ const ref = tuning?.ref ?? song?.ref;
1860
+ const refFrom =
1861
+ tuning?.ref === undefined && ref !== undefined ? " · song" : "";
1862
+ const root = tuning?.root ?? song?.root;
1863
+ const rootFrom =
1864
+ tuning?.root === undefined && root !== undefined ? " · song" : "";
1865
+ const nodes: MenuNode[] = [
1866
+ first,
1867
+ {
1868
+ kind: "info",
1869
+ label: "steps (cents)",
1870
+ value: tuningStepsText(tuning),
1871
+ help: describeTuning(tuning),
1872
+ },
1873
+ {
1874
+ kind: "number",
1875
+ label: "reference A4",
1876
+ help: "12-TET A4 in Hz; the root sounds at its 12-TET pitch from it · x: 440",
1877
+ value: ref,
1878
+ off: "440 Hz",
1879
+ start: 440,
1880
+ min: TUNING_LIMITS.minRefHz,
1881
+ max: TUNING_LIMITS.maxRefHz,
1882
+ step: linear(1, TUNING_LIMITS.minRefHz, TUNING_LIMITS.maxRefHz),
1883
+ format: (value) => `${num(value)} Hz${refFrom}`,
1884
+ command: (value) => `${prefix} ref ${num(value)}`,
1885
+ reset: `${prefix} ref off`,
1886
+ },
1887
+ {
1888
+ kind: "choice",
1889
+ label: "root",
1890
+ help: song
1891
+ ? `the key that plays degree 0; auto follows the song tuning's root${rootFrom ? " (now the song's)" : ""}`
1892
+ : "the key that plays degree 0; auto follows the song key",
1893
+ value: rootName(root),
1894
+ options: ROOT_OPTIONS,
1895
+ command: (option) => `${prefix} root ${option}`,
1896
+ },
1897
+ {
1898
+ kind: "choice",
1899
+ label: "keys",
1900
+ help: "linear: one key per step (Scala) · nearest: each of 12 keys to its nearest step",
1901
+ value: tuning?.map ?? "linear",
1902
+ options: TUNING_MAPS,
1903
+ command: (option) => `${prefix} map ${option}`,
1904
+ },
1905
+ {
1906
+ kind: "entry",
1907
+ label: "equal steps",
1908
+ value: tuning?.edo ? String(tuning.edo) : "",
1909
+ placeholder: "steps per octave, e.g. 22",
1910
+ command: (text) =>
1911
+ /^\d+$/.test(text.trim()) ? `${prefix} edo ${text.trim()}` : undefined,
1912
+ example: `${prefix} edo 22`,
1913
+ help: "n equal divisions of the octave",
1914
+ },
1915
+ {
1916
+ kind: "entry",
1917
+ label: "Scala file",
1918
+ value: tuning?.scl ?? "",
1919
+ placeholder: "path to a .scl file",
1920
+ command: (text) =>
1921
+ text.trim() ? `${prefix} scl ${text.trim()}` : undefined,
1922
+ example: `${prefix} scl tunings/meantone.scl`,
1923
+ help: "a Scala scale; files outside the project are copied into tunings/",
1924
+ },
1925
+ {
1926
+ kind: "entry",
1927
+ label: "ratios",
1928
+ value: tuning?.ratios?.join(" ") ?? "",
1929
+ placeholder: "just ratios, the last is the period",
1930
+ command: (text) =>
1931
+ text.trim() ? `${prefix} ratios ${text.trim()}` : undefined,
1932
+ example: `${prefix} ratios 9/8 5/4 3/2 2/1`,
1933
+ help: "a just-intonation table: ratios for degrees 1..n",
1934
+ },
1935
+ {
1936
+ kind: "entry",
1937
+ label: "cents",
1938
+ value: tuning?.cents && !tuning.scl ? tuning.cents.join(" ") : "",
1939
+ placeholder: "cents, the last is the period",
1940
+ command: (text) =>
1941
+ /^[-+\d.\s]+$/.test(text.trim())
1942
+ ? `${prefix} cents ${text.trim()}`
1943
+ : undefined,
1944
+ example: `${prefix} cents 231 474 717 955 1200`,
1945
+ help: "a table in cents for degrees 1..n",
1946
+ },
1947
+ {
1948
+ kind: "entry",
1949
+ label: "keyboard map (.kbm)",
1950
+ value: tuning?.kbm ?? "",
1951
+ placeholder: "path to a .kbm file, or off",
1952
+ command: (text) =>
1953
+ text.trim() ? `${prefix} kbm ${text.trim()}` : undefined,
1954
+ example: `${prefix} kbm tunings/white.kbm`,
1955
+ help: "a Scala keyboard map: which key plays which degree; off removes it",
1956
+ },
1957
+ ];
1958
+ return nodes;
1959
+ }
1960
+
1961
+ /** Project › tuning & scale: the song tuning and the song scale. */
1962
+ function songTuningNodes(context: MenuContext): MenuNode[] {
1963
+ const score = context.score;
1964
+ const key = parseKey(score.key ?? undefined);
1965
+ const scale = key ? (key.scale ?? key.mode) : "major";
1966
+ const tonic = key ? keyName(key).split(" ")[0]! : "C";
1967
+ return [
1968
+ ...tuningRows(score.tuning, "tuning", {
1969
+ kind: "choice",
1970
+ label: "tuning",
1971
+ help: "the song tuning; every track without its own follows it",
1972
+ value: tuningLabel(score.tuning),
1973
+ options: TUNING_NAMES,
1974
+ command: (option) =>
1975
+ option === "12-tet" ? "tuning off" : `tuning ${option}`,
1976
+ }),
1977
+ {
1978
+ kind: "choice",
1979
+ label: "scale",
1980
+ help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
1981
+ value: scale,
1982
+ options: [...MODE_NAMES, ...SCALE_NAMES],
1983
+ command: (option) => `scale ${option}`,
1984
+ },
1985
+ {
1986
+ kind: "choice",
1987
+ label: "tonic",
1988
+ help: "the song key's tonic; keeps the scale",
1989
+ value: tonic,
1990
+ options: TONICS,
1991
+ command: (option) => `scale ${option} ${scale}`,
1992
+ },
1993
+ {
1994
+ kind: "action",
1995
+ label: "list tunings",
1996
+ command: "tuning list",
1997
+ help: "every library tuning, by family",
1998
+ },
1999
+ {
2000
+ kind: "action",
2001
+ label: "list scales",
2002
+ command: "scale list",
2003
+ help: "every mode and scale, by family",
2004
+ },
2005
+ ];
2006
+ }
2007
+
2008
+ /** Sound › tuning: the focused track's own tuning, or the song's. */
2009
+ function trackTuningNodes(context: MenuContext): MenuNode[] {
2010
+ const track = focused(context);
2011
+ return tuningRows(
2012
+ track?.tuning,
2013
+ "tuning track",
2014
+ {
2015
+ kind: "choice",
2016
+ label: "tuning",
2017
+ help: "song: follow the song tuning · or a tuning for this track only",
2018
+ value: track?.tuning ? tuningLabel(track.tuning) : "song",
2019
+ options: ["song", ...TUNING_NAMES],
2020
+ command: (option) =>
2021
+ option === "song" ? "tuning track off" : `tuning track ${option}`,
2022
+ },
2023
+ context.score.tuning ?? {},
2024
+ );
2025
+ }
2026
+
1542
2027
  // ── controller ────────────────────────────────────────────────────────
1543
2028
 
1544
2029
  type Frame = {
1545
2030
  title: string;
2031
+ /** The submenu's node id (`chords`), for `EditMenu.section`. */
2032
+ id?: string;
1546
2033
  build: (context: MenuContext) => MenuNode[];
1547
2034
  index: number;
1548
2035
  query: string;
@@ -1573,6 +2060,7 @@ function childFrame(
1573
2060
  ): Frame {
1574
2061
  return {
1575
2062
  title: node.label,
2063
+ id: node.id,
1576
2064
  index: 0,
1577
2065
  query: "",
1578
2066
  hover: `menu:${node.id}`,
@@ -1603,6 +2091,11 @@ export class EditMenu {
1603
2091
  return this.filtering || this.entry !== undefined;
1604
2092
  }
1605
2093
 
2094
+ /** The root section open now (`chords`), or undefined at the root. */
2095
+ get section(): string | undefined {
2096
+ return this.stack[1]?.id;
2097
+ }
2098
+
1606
2099
  /** Open at the root, or at a section id (`effects`, `automation`). */
1607
2100
  show(context: MenuContext, section?: string): void {
1608
2101
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
@@ -1624,6 +2117,88 @@ export class EditMenu {
1624
2117
  }
1625
2118
  }
1626
2119
 
2120
+ /**
2121
+ * Open on the level a bare parameter command edits, for the fader drawer:
2122
+ * `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
2123
+ * in Effects or its "more effects" list). Returns the label to focus, or
2124
+ * undefined when `command` names no such parameter (the menu is closed).
2125
+ */
2126
+ showFader(context: MenuContext, command: string): string | undefined {
2127
+ const words = command.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
2128
+ if (words.length === 1 && (words[0] === "volume" || words[0] === "pan")) {
2129
+ this.show(context, "mix");
2130
+ if (this.stack.length < 2) return this.fail();
2131
+ return this.focusLabel(context, words[0]) ?? this.fail();
2132
+ }
2133
+ if (words[0] !== "fx" || words.length < 2 || words.length > 3)
2134
+ return undefined;
2135
+ const effect = parseEffectName(words[1]!);
2136
+ if (!effect) return undefined;
2137
+ const param = words[2] ? parseParamName(effect, words[2]) : undefined;
2138
+ if (words[2] && !param) return undefined;
2139
+ this.show(context, "effects");
2140
+ if (this.stack.length < 2) return this.fail();
2141
+ if (!this.descend(context, effect)) {
2142
+ if (
2143
+ !this.descend(context, "more effects") ||
2144
+ !this.descend(context, effect)
2145
+ )
2146
+ return this.fail();
2147
+ }
2148
+ const fields = this.faderFields(context);
2149
+ const label = param
2150
+ ? fields.find((field) => field.label === param)?.label
2151
+ : fields.find((field) => field.kind === "number")?.label;
2152
+ if (!label) return this.fail();
2153
+ return this.focusLabel(context, label) ?? this.fail();
2154
+ }
2155
+
2156
+ private fail(): undefined {
2157
+ this.close();
2158
+ return undefined;
2159
+ }
2160
+
2161
+ /** Step into the child menu `id` of the current level. */
2162
+ private descend(context: MenuContext, id: string): boolean {
2163
+ const frame = this.stack.at(-1);
2164
+ if (!frame) return false;
2165
+ const nodes = frame.build(context);
2166
+ const index = nodes.findIndex(
2167
+ (node) => node.kind === "menu" && node.id === id,
2168
+ );
2169
+ const node = nodes[index];
2170
+ if (node?.kind !== "menu") return false;
2171
+ frame.index = index;
2172
+ this.stack.push(childFrame(frame, node));
2173
+ return true;
2174
+ }
2175
+
2176
+ /** Put the cursor on the row labelled `label`; returns it when found. */
2177
+ private focusLabel(context: MenuContext, label: string): string | undefined {
2178
+ const frame = this.stack.at(-1);
2179
+ if (!frame) return undefined;
2180
+ const index = frame
2181
+ .build(context)
2182
+ .findIndex((node) => node.label === label);
2183
+ if (index < 0) return undefined;
2184
+ frame.index = index;
2185
+ return label;
2186
+ }
2187
+
2188
+ /** Select row `index` of the current level (a click), clearing typing. */
2189
+ select(context: MenuContext, index: number): void {
2190
+ const frame = this.stack.at(-1);
2191
+ if (!frame) return;
2192
+ this.entry = undefined;
2193
+ this.filtering = false;
2194
+ frame.index = clamp(index, 0, Math.max(0, this.nodes(context).length - 1));
2195
+ }
2196
+
2197
+ /** The row index under the cursor (the picker's). */
2198
+ get index(): number {
2199
+ return this.stack.at(-1)?.index ?? 0;
2200
+ }
2201
+
1627
2202
  close(): void {
1628
2203
  this.stack = [];
1629
2204
  this.filtering = false;
@@ -1777,7 +2352,7 @@ export class EditMenu {
1777
2352
  if (node.kind === "action")
1778
2353
  return frame.hover &&
1779
2354
  context.audition?.looping &&
1780
- isStageable(node.command)
2355
+ (context.audition.stageable ?? isStageable)(node.command)
1781
2356
  ? { type: "choose", command: node.command, key: frame.hover }
1782
2357
  : { type: "run", command: node.command };
1783
2358
  if (node.kind === "choice") {
@@ -1806,11 +2381,8 @@ export class EditMenu {
1806
2381
  });
1807
2382
  return { type: "handled" };
1808
2383
  }
1809
- if (
1810
- node.kind === "entry" ||
1811
- node.kind === "number" ||
1812
- node.kind === "point"
1813
- ) {
2384
+ if (node.kind === "number") return { type: "fader", label: node.label };
2385
+ if (node.kind === "entry" || node.kind === "point") {
1814
2386
  this.entry = { label: node.label, buffer: "" };
1815
2387
  return { type: "handled" };
1816
2388
  }
@@ -1822,7 +2394,8 @@ export class EditMenu {
1822
2394
  private hovered(frame: Frame, context: MenuContext): MenuResult {
1823
2395
  if (!frame.hover || !context.audition?.looping) return { type: "handled" };
1824
2396
  const node = this.selected(context);
1825
- if (node?.kind !== "action" || !isStageable(node.command))
2397
+ const stageable = context.audition?.stageable ?? isStageable;
2398
+ if (node?.kind !== "action" || !stageable(node.command))
1826
2399
  return { type: "handled" };
1827
2400
  frame.hovering = true;
1828
2401
  return { type: "hover", command: node.command, key: frame.hover };
@@ -1910,6 +2483,33 @@ export class EditMenu {
1910
2483
  return { type: "handled" };
1911
2484
  }
1912
2485
 
2486
+ /**
2487
+ * The current level's number and choice rows as fader fields (the drawer's
2488
+ * related params: every filter control, every reverb control), built
2489
+ * from `context` (the staged score while auditioning).
2490
+ */
2491
+ faderFields(context: MenuContext): FaderSpec[] {
2492
+ const frame = this.stack.at(-1);
2493
+ if (!frame) return [];
2494
+ return faderSpecs(frame.build(context));
2495
+ }
2496
+
2497
+ /** The same fields as committed, for the drawer's `staged ← committed`. */
2498
+ faderCommitted(context: MenuContext): FaderSpec[] {
2499
+ const audition = context.audition;
2500
+ if (!audition?.dirty) return this.faderFields(context);
2501
+ return this.faderFields({
2502
+ ...context,
2503
+ score: audition.committed,
2504
+ ...(audition.committedChords ? { chords: audition.committedChords } : {}),
2505
+ });
2506
+ }
2507
+
2508
+ /** The breadcrumb, for the drawer's title. */
2509
+ get crumbs(): string {
2510
+ return this.stack.map((level) => level.title).join(" › ");
2511
+ }
2512
+
1913
2513
  /** The picker the TUI draws for the current level. */
1914
2514
  view(context: MenuContext): MenuView {
1915
2515
  const frame = this.stack.at(-1);
@@ -1932,6 +2532,9 @@ export class EditMenu {
1932
2532
  for (const node of frame.build({
1933
2533
  ...context,
1934
2534
  score: audition.committed,
2535
+ ...(audition.committedChords
2536
+ ? { chords: audition.committedChords }
2537
+ : {}),
1935
2538
  }))
1936
2539
  before.set(node.label, valueText(node));
1937
2540
  const items = nodes.map((node, at) => {
@@ -2004,6 +2607,37 @@ function placeholderFor(node: MenuNode | undefined): string {
2004
2607
  return "";
2005
2608
  }
2006
2609
 
2610
+ /** Menu rows → fader fields: numbers and choices, in row order. */
2611
+ export function faderSpecs(nodes: readonly MenuNode[]): FaderSpec[] {
2612
+ const fields: FaderSpec[] = [];
2613
+ for (const node of nodes) {
2614
+ if (node.kind === "number")
2615
+ fields.push({
2616
+ kind: "number",
2617
+ label: node.label,
2618
+ value: node.value,
2619
+ min: node.min,
2620
+ max: node.max,
2621
+ step: node.step,
2622
+ format: node.format,
2623
+ command: node.command,
2624
+ parse: (text) => entryCommand(node, text),
2625
+ reset: node.reset,
2626
+ start: node.start,
2627
+ off: node.off,
2628
+ });
2629
+ else if (node.kind === "choice")
2630
+ fields.push({
2631
+ kind: "choice",
2632
+ label: node.label,
2633
+ value: node.value,
2634
+ options: node.options,
2635
+ command: node.command,
2636
+ });
2637
+ }
2638
+ return fields;
2639
+ }
2640
+
2007
2641
  function entryCommand(node: MenuNode, text: string): string | undefined {
2008
2642
  const trimmed = text.trim();
2009
2643
  if (node.kind === "entry") return node.command(trimmed);
@@ -2115,11 +2749,17 @@ export const MENU_SECTIONS = [
2115
2749
  "rhythm",
2116
2750
  "chords",
2117
2751
  "mix",
2752
+ "master",
2118
2753
  "project",
2754
+ "arrange",
2119
2755
  // Older names, still accepted.
2120
2756
  "parameters",
2121
2757
  "sounds",
2122
2758
  "track",
2123
2759
  "automation",
2124
2760
  "transport",
2761
+ // Subsections.
2762
+ "tempo",
2763
+ "meter",
2764
+ "time",
2125
2765
  ] as const;