@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.
- package/CHANGELOG.md +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- 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 {
|
|
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:
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
1503
|
-
command: (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
|
-
|
|
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
|
-
|
|
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;
|