@hraness/dawg 0.4.1 → 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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  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/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. 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,
@@ -215,6 +249,8 @@ export type MenuResult =
215
249
  | { type: "unhover"; key: string }
216
250
  /** Chose a list item while the loop plays: stage it for good. */
217
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 }
218
254
  | { type: "pass" };
219
255
 
220
256
  export type MenuView = Readonly<{
@@ -389,10 +425,20 @@ export function rootNodes(context: MenuContext): MenuNode[] {
389
425
  kind: "menu",
390
426
  id: "project",
391
427
  label: "Project",
392
- detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
393
- help: "tempo, meter, loop length, play, click and count-in",
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",
394
432
  build: transportNodes,
395
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
+ },
396
442
  ];
397
443
  }
398
444
 
@@ -401,20 +447,56 @@ const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
401
447
  sound: ["sound"],
402
448
  parameters: ["sound"],
403
449
  sounds: ["sound", "browse"],
450
+ performance: ["sound", "performance"],
451
+ expression: ["sound", "performance"],
404
452
  effects: ["effects"],
405
453
  rhythm: ["rhythm"],
406
454
  chords: ["chords"],
407
455
  mix: ["mix"],
408
456
  track: ["mix"],
409
457
  automation: ["mix", "automation"],
458
+ master: ["mix", "master"],
410
459
  project: ["project"],
411
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"],
412
469
  };
413
470
 
414
471
  /** Sound: instrument and voice first, then the sound browser. */
415
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
+ : [];
416
489
  return [
417
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
+ },
418
500
  {
419
501
  kind: "menu",
420
502
  id: "browse",
@@ -464,9 +546,186 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
464
546
  help: "points that move a value over the loop",
465
547
  build: automationNodes,
466
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
+ },
467
559
  ];
468
560
  }
469
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
+ },
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;
727
+ }
728
+
470
729
  function chordsDetail(context: MenuContext): string {
471
730
  const chords = context.chords ?? defaultChordSettings();
472
731
  const key = parseKey(context.score.key ?? undefined);
@@ -478,7 +737,8 @@ function chordNodes(context: MenuContext): MenuNode[] {
478
737
  const chords = context.chords ?? defaultChordSettings();
479
738
  const key = parseKey(context.score.key ?? undefined);
480
739
  const tonic = key ? keyName(key).split(" ")[0]! : "C";
481
- 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";
482
742
  const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
483
743
  return [
484
744
  {
@@ -500,9 +760,9 @@ function chordNodes(context: MenuContext): MenuNode[] {
500
760
  {
501
761
  kind: "choice",
502
762
  label: "key mode",
503
- help: "major, minor or a church mode",
763
+ help: "major, minor, a church mode or any library scale",
504
764
  value: mode,
505
- options: MODE_NAMES,
765
+ options: [...MODE_NAMES, ...SCALE_NAMES],
506
766
  command: (option) => `key ${tonic} ${option}`,
507
767
  },
508
768
  {
@@ -1310,7 +1570,7 @@ function laneNodes(
1310
1570
  value: "two points",
1311
1571
  placeholder: "from-beat:value to-beat:value",
1312
1572
  command: (text) => pointsCommand(lane, text, 2),
1313
- 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)}`,
1314
1574
  },
1315
1575
  ];
1316
1576
  for (const point of points)
@@ -1506,9 +1766,10 @@ function transportNodes(context: MenuContext): MenuNode[] {
1506
1766
  min: 1,
1507
1767
  max: SCORE_LIMITS.maxBeatsPerBar,
1508
1768
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
1509
- format: (value) => `${value}/4`,
1510
- command: (value) => `meter ${Math.round(value)}`,
1769
+ format: (value) => `${value}/${openingUnit(score)}`,
1770
+ command: (value) => openingMeterCommand(score, value),
1511
1771
  },
1772
+ tempoMenuNode(context),
1512
1773
  {
1513
1774
  kind: "number",
1514
1775
  label: "loop length",
@@ -1543,7 +1804,224 @@ function transportNodes(context: MenuContext): MenuNode[] {
1543
1804
  options: ["0", "1", "2"],
1544
1805
  command: (option) => `/count-in ${option}`,
1545
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
+ },
1815
+ ];
1816
+ }
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
+ },
1546
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
+ );
1547
2025
  }
1548
2026
 
1549
2027
  // ── controller ────────────────────────────────────────────────────────
@@ -1639,6 +2117,88 @@ export class EditMenu {
1639
2117
  }
1640
2118
  }
1641
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
+
1642
2202
  close(): void {
1643
2203
  this.stack = [];
1644
2204
  this.filtering = false;
@@ -1821,11 +2381,8 @@ export class EditMenu {
1821
2381
  });
1822
2382
  return { type: "handled" };
1823
2383
  }
1824
- if (
1825
- node.kind === "entry" ||
1826
- node.kind === "number" ||
1827
- node.kind === "point"
1828
- ) {
2384
+ if (node.kind === "number") return { type: "fader", label: node.label };
2385
+ if (node.kind === "entry" || node.kind === "point") {
1829
2386
  this.entry = { label: node.label, buffer: "" };
1830
2387
  return { type: "handled" };
1831
2388
  }
@@ -1926,6 +2483,33 @@ export class EditMenu {
1926
2483
  return { type: "handled" };
1927
2484
  }
1928
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
+
1929
2513
  /** The picker the TUI draws for the current level. */
1930
2514
  view(context: MenuContext): MenuView {
1931
2515
  const frame = this.stack.at(-1);
@@ -2023,6 +2607,37 @@ function placeholderFor(node: MenuNode | undefined): string {
2023
2607
  return "";
2024
2608
  }
2025
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
+
2026
2641
  function entryCommand(node: MenuNode, text: string): string | undefined {
2027
2642
  const trimmed = text.trim();
2028
2643
  if (node.kind === "entry") return node.command(trimmed);
@@ -2134,11 +2749,17 @@ export const MENU_SECTIONS = [
2134
2749
  "rhythm",
2135
2750
  "chords",
2136
2751
  "mix",
2752
+ "master",
2137
2753
  "project",
2754
+ "arrange",
2138
2755
  // Older names, still accepted.
2139
2756
  "parameters",
2140
2757
  "sounds",
2141
2758
  "track",
2142
2759
  "automation",
2143
2760
  "transport",
2761
+ // Subsections.
2762
+ "tempo",
2763
+ "meter",
2764
+ "time",
2144
2765
  ] as const;