@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.
- package/CHANGELOG.md +70 -0
- package/DAWG.md +293 -31
- 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/common.ts +30 -1
- 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 +92 -2
- package/src/audio/engine.ts +90 -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 +74 -14
- 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 +301 -26
- 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 +709 -33
- 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 +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -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 +63 -1
- 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,
|
|
@@ -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:
|
|
393
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
1510
|
-
command: (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
|
-
|
|
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;
|