@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -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 +29 -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 +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -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/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -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/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- 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/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -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 +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- 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/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- 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 +459 -43
- 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,12 +10,23 @@
|
|
|
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 { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
|
|
17
|
+
import type { FaderSpec } from "./fader.ts";
|
|
18
|
+
import {
|
|
19
|
+
openingMeterCommand,
|
|
20
|
+
openingUnit,
|
|
21
|
+
tempoDetail,
|
|
22
|
+
tempoMenuNode,
|
|
23
|
+
} from "./menu-time.ts";
|
|
14
24
|
import {
|
|
15
25
|
AUTOMATION_PARAMETERS,
|
|
16
26
|
automationPoints,
|
|
17
27
|
automationRange,
|
|
18
28
|
REVERB_IR_BUILTINS,
|
|
29
|
+
SAMPLE_FIT_MODES,
|
|
19
30
|
SAMPLE_UNITS,
|
|
20
31
|
SCORE_LIMITS,
|
|
21
32
|
isTrackAutomationParameter,
|
|
@@ -33,6 +44,9 @@ import {
|
|
|
33
44
|
CORE_EFFECTS,
|
|
34
45
|
EFFECT_NAMES,
|
|
35
46
|
FX_LANES,
|
|
47
|
+
RIG_PRESETS,
|
|
48
|
+
RIG_STAGES,
|
|
49
|
+
rigPresetOf,
|
|
36
50
|
effectPresetNames,
|
|
37
51
|
effectSpec,
|
|
38
52
|
type EffectName,
|
|
@@ -40,7 +54,22 @@ import {
|
|
|
40
54
|
type NumberParam,
|
|
41
55
|
type ParamSpec,
|
|
42
56
|
} from "../../core/fx.ts";
|
|
43
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
effectValues,
|
|
59
|
+
parseEffectName,
|
|
60
|
+
parseParamName,
|
|
61
|
+
} from "../commands/fx.ts";
|
|
62
|
+
import {
|
|
63
|
+
LOUDNESS_TARGET_NAMES,
|
|
64
|
+
LOUDNESS_TARGETS,
|
|
65
|
+
MASTER_LIMITS,
|
|
66
|
+
MASTER_PRESETS,
|
|
67
|
+
MASTER_SPECS,
|
|
68
|
+
MASTER_UNITS,
|
|
69
|
+
describeMaster,
|
|
70
|
+
describeUnit,
|
|
71
|
+
type MasterUnit,
|
|
72
|
+
} from "../../core/master.ts";
|
|
44
73
|
import { SAMPLE_CONTROLS, type SampleControl } from "../commands/sample.ts";
|
|
45
74
|
import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
46
75
|
import {
|
|
@@ -53,6 +82,7 @@ import { kitCatalog } from "../audio/kits.ts";
|
|
|
53
82
|
import { SYNTH_KIT_NAMES } from "../../core/kits.ts";
|
|
54
83
|
import { DRUM_PATTERNS } from "../../core/sdk/v1.ts";
|
|
55
84
|
import { isDrumInstrument } from "../../core/drums.ts";
|
|
85
|
+
import { loopTicksOf } from "../../core/tempo.ts";
|
|
56
86
|
import {
|
|
57
87
|
SYNTH_GROUPS,
|
|
58
88
|
SYNTH_PARAMS,
|
|
@@ -60,6 +90,22 @@ import {
|
|
|
60
90
|
SYNTH_SIMPLE,
|
|
61
91
|
normalizeSynth,
|
|
62
92
|
} from "../../core/synth.ts";
|
|
93
|
+
import {
|
|
94
|
+
isStringTrack,
|
|
95
|
+
STRING_PARAMS,
|
|
96
|
+
STRING_PRESET_NAMES,
|
|
97
|
+
STRING_PRESETS,
|
|
98
|
+
STRING_SIMPLE_PARAMS,
|
|
99
|
+
stringPresetOf,
|
|
100
|
+
} from "../../core/strings.ts";
|
|
101
|
+
import {
|
|
102
|
+
KEYS_PARAMS,
|
|
103
|
+
KEYS_PRESETS,
|
|
104
|
+
KEYS_SIMPLE,
|
|
105
|
+
PIANO_FAMILIES,
|
|
106
|
+
isPianoFamily,
|
|
107
|
+
resolvedKeys,
|
|
108
|
+
} from "../../core/keys.ts";
|
|
63
109
|
import type { PickerItem } from "../../tui/app.ts";
|
|
64
110
|
import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
|
|
65
111
|
import {
|
|
@@ -76,10 +122,25 @@ import {
|
|
|
76
122
|
PERFORM_MODES,
|
|
77
123
|
PROGRESSION_PRESETS,
|
|
78
124
|
PROGRESSION_STYLES,
|
|
125
|
+
SCALE_NAMES,
|
|
79
126
|
SPREADS,
|
|
80
127
|
keyName,
|
|
81
128
|
parseKey,
|
|
82
129
|
} from "../../core/chords.ts";
|
|
130
|
+
import {
|
|
131
|
+
TUNING_LIMITS,
|
|
132
|
+
TUNING_MAPS,
|
|
133
|
+
TUNING_NAMES,
|
|
134
|
+
describeTuning,
|
|
135
|
+
type Tuning,
|
|
136
|
+
} from "../../core/tuning.ts";
|
|
137
|
+
import { rootName, tuningStepsText } from "../commands/tuning.ts";
|
|
138
|
+
import {
|
|
139
|
+
granularBrowseNodes,
|
|
140
|
+
granularMenuDetail,
|
|
141
|
+
granularMenuLabel,
|
|
142
|
+
granularMenuNodes,
|
|
143
|
+
} from "./granular-menu.ts";
|
|
83
144
|
import {
|
|
84
145
|
ARP_RATES,
|
|
85
146
|
CHORD_MODES,
|
|
@@ -215,6 +276,8 @@ export type MenuResult =
|
|
|
215
276
|
| { type: "unhover"; key: string }
|
|
216
277
|
/** Chose a list item while the loop plays: stage it for good. */
|
|
217
278
|
| { type: "choose"; command: string; key: string }
|
|
279
|
+
/** Enter on a number row: open the fader drawer on this level's values. */
|
|
280
|
+
| { type: "fader"; label: string }
|
|
218
281
|
| { type: "pass" };
|
|
219
282
|
|
|
220
283
|
export type MenuView = Readonly<{
|
|
@@ -285,7 +348,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
|
285
348
|
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
286
349
|
|
|
287
350
|
/** Nudge for a number spec: linear by its step, or a sixth of an octave. */
|
|
288
|
-
function specStep(
|
|
351
|
+
export function specStep(
|
|
289
352
|
spec: NumberParam,
|
|
290
353
|
): (value: number, direction: 1 | -1) => number {
|
|
291
354
|
if (spec.step === "log")
|
|
@@ -318,7 +381,12 @@ function laneLabel(lane: AutomationParameter): string {
|
|
|
318
381
|
if (!info) return lane;
|
|
319
382
|
const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
|
|
320
383
|
const owner =
|
|
321
|
-
info.effect === "synth"
|
|
384
|
+
info.effect === "synth" ||
|
|
385
|
+
info.effect === "string" ||
|
|
386
|
+
info.effect === "keys" ||
|
|
387
|
+
info.effect === "modal"
|
|
388
|
+
? info.effect
|
|
389
|
+
: effectSpec(info.effect).label;
|
|
322
390
|
return `${owner} ${info.param}${unit}`;
|
|
323
391
|
}
|
|
324
392
|
|
|
@@ -389,10 +457,20 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
389
457
|
kind: "menu",
|
|
390
458
|
id: "project",
|
|
391
459
|
label: "Project",
|
|
392
|
-
detail:
|
|
393
|
-
|
|
460
|
+
detail: context.score.time
|
|
461
|
+
? `${tempoDetail(context.score)} · ${context.score.bars} bars`
|
|
462
|
+
: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
|
|
463
|
+
help: "tempo, meter, tempo map, loop length, play, click and count-in",
|
|
394
464
|
build: transportNodes,
|
|
395
465
|
},
|
|
466
|
+
{
|
|
467
|
+
kind: "menu",
|
|
468
|
+
id: "arrange",
|
|
469
|
+
label: "Arrange",
|
|
470
|
+
detail: arrangeDetail(context),
|
|
471
|
+
help: "song sections, the form, builds, drops and fills",
|
|
472
|
+
build: arrangeNodes,
|
|
473
|
+
},
|
|
396
474
|
];
|
|
397
475
|
}
|
|
398
476
|
|
|
@@ -401,20 +479,90 @@ const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
401
479
|
sound: ["sound"],
|
|
402
480
|
parameters: ["sound"],
|
|
403
481
|
sounds: ["sound", "browse"],
|
|
482
|
+
performance: ["sound", "performance"],
|
|
483
|
+
expression: ["sound", "performance"],
|
|
404
484
|
effects: ["effects"],
|
|
405
485
|
rhythm: ["rhythm"],
|
|
406
486
|
chords: ["chords"],
|
|
407
487
|
mix: ["mix"],
|
|
408
488
|
track: ["mix"],
|
|
409
489
|
automation: ["mix", "automation"],
|
|
490
|
+
master: ["mix", "master"],
|
|
410
491
|
project: ["project"],
|
|
411
492
|
transport: ["project"],
|
|
493
|
+
tempo: ["project", "tempo"],
|
|
494
|
+
meter: ["project", "tempo"],
|
|
495
|
+
time: ["project", "tempo"],
|
|
496
|
+
tuning: ["project", "tuning"],
|
|
497
|
+
scale: ["project", "tuning"],
|
|
498
|
+
arrange: ["arrange"],
|
|
499
|
+
sections: ["arrange"],
|
|
500
|
+
form: ["arrange"],
|
|
412
501
|
};
|
|
413
502
|
|
|
414
503
|
/** Sound: instrument and voice first, then the sound browser. */
|
|
415
504
|
function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
505
|
+
const track = focused(context);
|
|
506
|
+
const tuning: MenuNode[] =
|
|
507
|
+
track && !isDrumInstrument(track.instrument)
|
|
508
|
+
? [
|
|
509
|
+
{
|
|
510
|
+
kind: "menu",
|
|
511
|
+
id: "track-tuning",
|
|
512
|
+
label: "tuning",
|
|
513
|
+
detail: track.tuning
|
|
514
|
+
? tuningLabel(track.tuning)
|
|
515
|
+
: `song · ${tuningLabel(context.score.tuning)}`,
|
|
516
|
+
help: "this track's tuning, or follow the song",
|
|
517
|
+
build: trackTuningNodes,
|
|
518
|
+
},
|
|
519
|
+
]
|
|
520
|
+
: [];
|
|
521
|
+
const grainLabel = granularMenuLabel(track);
|
|
522
|
+
const granular: MenuNode[] =
|
|
523
|
+
track && grainLabel
|
|
524
|
+
? [
|
|
525
|
+
{
|
|
526
|
+
kind: "menu",
|
|
527
|
+
id: "granular",
|
|
528
|
+
label: grainLabel,
|
|
529
|
+
detail: granularMenuDetail(track),
|
|
530
|
+
help: "grain clouds: preset, source, position, scan, grain size, pitch, shimmer",
|
|
531
|
+
build: (inner) => granularMenuNodes(focused(inner)),
|
|
532
|
+
},
|
|
533
|
+
]
|
|
534
|
+
: [];
|
|
535
|
+
const keys: MenuNode[] =
|
|
536
|
+
track && isPianoFamily(track.instrument) && track.keys
|
|
537
|
+
? [
|
|
538
|
+
{
|
|
539
|
+
kind: "menu",
|
|
540
|
+
id: "keys",
|
|
541
|
+
label: "keys",
|
|
542
|
+
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
543
|
+
help: "the modelled piano: preset, touch, hammers, dampers, stretch",
|
|
544
|
+
build: (inner) => {
|
|
545
|
+
const current = focused(inner);
|
|
546
|
+
return current
|
|
547
|
+
? keysNodes(current, Object.keys(KEYS_PARAMS))
|
|
548
|
+
: [];
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
]
|
|
552
|
+
: [];
|
|
416
553
|
return [
|
|
417
554
|
...parameterNodes(context),
|
|
555
|
+
...granular,
|
|
556
|
+
...keys,
|
|
557
|
+
...tuning,
|
|
558
|
+
{
|
|
559
|
+
kind: "menu",
|
|
560
|
+
id: "performance",
|
|
561
|
+
label: "performance",
|
|
562
|
+
detail: performanceDetail(focused(context)),
|
|
563
|
+
help: "articulation, glide, bend, vibrato, pedal, velocity curve, humanize",
|
|
564
|
+
build: performanceNodes,
|
|
565
|
+
},
|
|
418
566
|
{
|
|
419
567
|
kind: "menu",
|
|
420
568
|
id: "browse",
|
|
@@ -464,7 +612,184 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
464
612
|
help: "points that move a value over the loop",
|
|
465
613
|
build: automationNodes,
|
|
466
614
|
},
|
|
615
|
+
{
|
|
616
|
+
kind: "menu",
|
|
617
|
+
id: "master",
|
|
618
|
+
label: "master",
|
|
619
|
+
detail: context.score.master
|
|
620
|
+
? describeMaster(context.score.master)
|
|
621
|
+
: "off · bypass",
|
|
622
|
+
help: "the song master on the summed mix: EQ, glue, tape, width, limiter",
|
|
623
|
+
build: masterNodes,
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Mix & automation > Master: the loudness target, then each unit in order. */
|
|
629
|
+
function masterNodes(context: MenuContext): MenuNode[] {
|
|
630
|
+
const master = context.score.master;
|
|
631
|
+
// A name only when the LUFS and the limiter ceiling both match it, and no
|
|
632
|
+
// other name shares them (apple and podcast are both -16 at -1 dBTP).
|
|
633
|
+
const matches = LOUDNESS_TARGET_NAMES.filter(
|
|
634
|
+
(name) =>
|
|
635
|
+
LOUDNESS_TARGETS[name].lufs === master?.target &&
|
|
636
|
+
LOUDNESS_TARGETS[name].ceiling ===
|
|
637
|
+
(master?.limiter?.ceiling ?? MASTER_LIMITS.safeCeiling),
|
|
638
|
+
);
|
|
639
|
+
const named =
|
|
640
|
+
matches.length === 1
|
|
641
|
+
? matches[0]
|
|
642
|
+
: matches.length > 1
|
|
643
|
+
? `${num(master!.target!)} LUFS`
|
|
644
|
+
: undefined;
|
|
645
|
+
const nodes: MenuNode[] = [
|
|
646
|
+
{
|
|
647
|
+
kind: "choice",
|
|
648
|
+
label: "target",
|
|
649
|
+
value: master?.target === undefined ? "off" : (named ?? "custom"),
|
|
650
|
+
options: ["off", ...LOUDNESS_TARGET_NAMES],
|
|
651
|
+
command: (name) => `master target ${name}`,
|
|
652
|
+
help: "named loudness target; loud ones also load a fast limiter",
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
kind: "number",
|
|
656
|
+
label: "target LUFS",
|
|
657
|
+
value: master?.target,
|
|
658
|
+
start: -14,
|
|
659
|
+
off: "off",
|
|
660
|
+
min: MASTER_LIMITS.minTarget,
|
|
661
|
+
max: MASTER_LIMITS.maxTarget,
|
|
662
|
+
step: (value, direction) =>
|
|
663
|
+
clamp(
|
|
664
|
+
Math.round((value + direction * 0.5) * 2) / 2,
|
|
665
|
+
MASTER_LIMITS.minTarget,
|
|
666
|
+
MASTER_LIMITS.maxTarget,
|
|
667
|
+
),
|
|
668
|
+
format: (value) => `${num(value)} LUFS`,
|
|
669
|
+
command: (value) => `master target ${num(value)}`,
|
|
670
|
+
reset: "master target off",
|
|
671
|
+
help: "integrated loudness renders normalize to (EBU R 128 / BS.1770)",
|
|
672
|
+
},
|
|
467
673
|
];
|
|
674
|
+
for (const unit of MASTER_UNITS) {
|
|
675
|
+
const spec = MASTER_SPECS[unit];
|
|
676
|
+
const values = master?.[unit];
|
|
677
|
+
nodes.push({
|
|
678
|
+
kind: "menu",
|
|
679
|
+
id: `master:${unit}`,
|
|
680
|
+
label: unit,
|
|
681
|
+
detail: values ? describeUnit(unit, values) : "off",
|
|
682
|
+
help: spec.doc,
|
|
683
|
+
build: (inner) => masterUnitNodes(inner, unit, false),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
nodes.push({
|
|
687
|
+
kind: "action",
|
|
688
|
+
label: "measure the mix",
|
|
689
|
+
command: "master measure",
|
|
690
|
+
help: "render and report LUFS, true peak, LRA, balance and correlation",
|
|
691
|
+
});
|
|
692
|
+
if (master)
|
|
693
|
+
nodes.push({
|
|
694
|
+
kind: "action",
|
|
695
|
+
label: "remove master",
|
|
696
|
+
command: "master off",
|
|
697
|
+
help: "bypass: the mix renders exactly as without a master",
|
|
698
|
+
});
|
|
699
|
+
return nodes;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** One master unit: on/off, preset, its simple params, then `advanced`. */
|
|
703
|
+
function masterUnitNodes(
|
|
704
|
+
context: MenuContext,
|
|
705
|
+
unit: MasterUnit,
|
|
706
|
+
advanced: boolean,
|
|
707
|
+
): MenuNode[] {
|
|
708
|
+
const spec = MASTER_SPECS[unit];
|
|
709
|
+
const values = context.score.master?.[unit];
|
|
710
|
+
const nodes: MenuNode[] = [];
|
|
711
|
+
if (!advanced) {
|
|
712
|
+
nodes.push({
|
|
713
|
+
kind: "toggle",
|
|
714
|
+
label: "on",
|
|
715
|
+
value: values !== undefined,
|
|
716
|
+
command: (on) => `master ${unit} ${on ? "on" : "off"}`,
|
|
717
|
+
help: `switch the master ${spec.label} on or off`,
|
|
718
|
+
});
|
|
719
|
+
const presets = Object.keys(MASTER_PRESETS[unit]);
|
|
720
|
+
if (presets.length > 0)
|
|
721
|
+
nodes.push({
|
|
722
|
+
kind: "choice",
|
|
723
|
+
label: "preset",
|
|
724
|
+
value: "—",
|
|
725
|
+
options: presets,
|
|
726
|
+
command: (preset) => `master ${unit} preset ${preset}`,
|
|
727
|
+
help: "a starting point; every value stays editable",
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const keys = advanced ? Object.keys(spec.params) : spec.simple;
|
|
731
|
+
for (const key of keys) {
|
|
732
|
+
const param = spec.params[key]!;
|
|
733
|
+
const current = values?.[key];
|
|
734
|
+
// With a target the search sets the limiter's drive; its gain is unused.
|
|
735
|
+
if (
|
|
736
|
+
unit === "limiter" &&
|
|
737
|
+
key === "gain" &&
|
|
738
|
+
context.score.master?.target !== undefined
|
|
739
|
+
) {
|
|
740
|
+
nodes.push({ kind: "info", label: key, value: "set by target" });
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (param.kind === "number") {
|
|
744
|
+
nodes.push({
|
|
745
|
+
kind: "number",
|
|
746
|
+
label: key,
|
|
747
|
+
value: typeof current === "number" ? current : undefined,
|
|
748
|
+
start: param.default,
|
|
749
|
+
off: values
|
|
750
|
+
? withUnit(formatParam(param, param.default), param.unit)
|
|
751
|
+
: "off",
|
|
752
|
+
min: param.min,
|
|
753
|
+
max: param.max,
|
|
754
|
+
step: specStep(param),
|
|
755
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
756
|
+
command: (value) =>
|
|
757
|
+
`master ${unit} ${key} ${formatParam(param, value)}`,
|
|
758
|
+
help: param.doc,
|
|
759
|
+
...(values
|
|
760
|
+
? {
|
|
761
|
+
reset: `master ${unit} ${key} ${formatParam(param, param.default)}`,
|
|
762
|
+
}
|
|
763
|
+
: {}),
|
|
764
|
+
});
|
|
765
|
+
} else if (param.kind === "enum") {
|
|
766
|
+
nodes.push({
|
|
767
|
+
kind: "choice",
|
|
768
|
+
label: key,
|
|
769
|
+
value: typeof current === "string" ? current : param.default,
|
|
770
|
+
options: param.values,
|
|
771
|
+
command: (option) => `master ${unit} ${key} ${option}`,
|
|
772
|
+
help: param.doc,
|
|
773
|
+
});
|
|
774
|
+
} else {
|
|
775
|
+
nodes.push({
|
|
776
|
+
kind: "toggle",
|
|
777
|
+
label: key,
|
|
778
|
+
value: typeof current === "boolean" ? current : param.default,
|
|
779
|
+
command: (on) => `master ${unit} ${key} ${on ? "on" : "off"}`,
|
|
780
|
+
help: param.doc,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (!advanced && Object.keys(spec.params).length > spec.simple.length)
|
|
785
|
+
nodes.push({
|
|
786
|
+
kind: "menu",
|
|
787
|
+
id: `master:${unit}:advanced`,
|
|
788
|
+
label: "advanced",
|
|
789
|
+
detail: `all ${Object.keys(spec.params).length} params`,
|
|
790
|
+
build: (inner) => masterUnitNodes(inner, unit, true),
|
|
791
|
+
});
|
|
792
|
+
return nodes;
|
|
468
793
|
}
|
|
469
794
|
|
|
470
795
|
function chordsDetail(context: MenuContext): string {
|
|
@@ -478,7 +803,8 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
478
803
|
const chords = context.chords ?? defaultChordSettings();
|
|
479
804
|
const key = parseKey(context.score.key ?? undefined);
|
|
480
805
|
const tonic = key ? keyName(key).split(" ")[0]! : "C";
|
|
481
|
-
|
|
806
|
+
// A library scale (D hijaz, C yaman) survives a tonic change.
|
|
807
|
+
const mode: string = key?.scale ?? key?.mode ?? "major";
|
|
482
808
|
const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
|
|
483
809
|
return [
|
|
484
810
|
{
|
|
@@ -500,9 +826,9 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
500
826
|
{
|
|
501
827
|
kind: "choice",
|
|
502
828
|
label: "key mode",
|
|
503
|
-
help: "major, minor
|
|
829
|
+
help: "major, minor, a church mode or any library scale",
|
|
504
830
|
value: mode,
|
|
505
|
-
options: MODE_NAMES,
|
|
831
|
+
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
506
832
|
command: (option) => `key ${tonic} ${option}`,
|
|
507
833
|
},
|
|
508
834
|
{
|
|
@@ -643,7 +969,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
643
969
|
}
|
|
644
970
|
|
|
645
971
|
function instrumentNode(track: Track): MenuNode {
|
|
646
|
-
const options: string[] = [...AVAILABLE_INSTRUMENTS];
|
|
972
|
+
const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
|
|
647
973
|
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
648
974
|
return {
|
|
649
975
|
kind: "choice",
|
|
@@ -696,6 +1022,30 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
696
1022
|
// A wavetable track also has the synth voice's envelope, filters and FM.
|
|
697
1023
|
if (isWavetableInstrument(track.instrument))
|
|
698
1024
|
nodes.push(...wavetableNodes(track, context.projectRoot));
|
|
1025
|
+
if (isStringTrack(track)) {
|
|
1026
|
+
nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
|
|
1027
|
+
nodes.push({
|
|
1028
|
+
kind: "menu",
|
|
1029
|
+
id: "string:advanced",
|
|
1030
|
+
label: "advanced",
|
|
1031
|
+
detail: `all ${Object.keys(STRING_PARAMS).length} params`,
|
|
1032
|
+
help: "every string parameter: exciter, loss, body, buzz, sympathetics",
|
|
1033
|
+
build: (inner) => {
|
|
1034
|
+
const current = focused(inner);
|
|
1035
|
+
return current
|
|
1036
|
+
? stringNodes(current, Object.keys(STRING_PARAMS), false)
|
|
1037
|
+
: [];
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
if (Object.keys(track.string ?? {}).some((key) => key !== "preset"))
|
|
1041
|
+
nodes.push({
|
|
1042
|
+
kind: "action",
|
|
1043
|
+
label: "reset to preset",
|
|
1044
|
+
command: "string reset",
|
|
1045
|
+
help: "drop this track's string overrides, keep the preset",
|
|
1046
|
+
});
|
|
1047
|
+
return nodes;
|
|
1048
|
+
}
|
|
699
1049
|
if (track.sampler) {
|
|
700
1050
|
nodes.push({
|
|
701
1051
|
kind: "info",
|
|
@@ -722,6 +1072,22 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
722
1072
|
label: "add a voice",
|
|
723
1073
|
value: "/sample <path> [as <voice>]",
|
|
724
1074
|
});
|
|
1075
|
+
} else if (isPianoFamily(track.instrument) && track.keys) {
|
|
1076
|
+
nodes.push(...keysNodes(track, KEYS_SIMPLE));
|
|
1077
|
+
nodes.push({
|
|
1078
|
+
kind: "menu",
|
|
1079
|
+
id: "keys:all",
|
|
1080
|
+
label: "all piano params",
|
|
1081
|
+
detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
|
|
1082
|
+
help: "every modelled piano parameter",
|
|
1083
|
+
build: (inner) => {
|
|
1084
|
+
const current = focused(inner);
|
|
1085
|
+
return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
} else if (track.instrument === "modal") {
|
|
1089
|
+
// 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
|
|
1090
|
+
nodes.push(...modalParameterNodes(track));
|
|
725
1091
|
} else if (!isDrumInstrument(track.instrument)) {
|
|
726
1092
|
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
727
1093
|
nodes.push({
|
|
@@ -829,6 +1195,44 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
|
829
1195
|
label: "cut / loopAt",
|
|
830
1196
|
value: `/sample set ${voice} cut hats · loopAt 2`,
|
|
831
1197
|
},
|
|
1198
|
+
// 0.6 fit: the sample's own tempo, its length in beats, and how it fits.
|
|
1199
|
+
{
|
|
1200
|
+
kind: "number",
|
|
1201
|
+
label: "bpm",
|
|
1202
|
+
value: ref.bpm,
|
|
1203
|
+
start: Math.round(context.score.tempoBpm),
|
|
1204
|
+
off: "off",
|
|
1205
|
+
min: SCORE_LIMITS.minSampleBpm,
|
|
1206
|
+
max: SCORE_LIMITS.maxSampleBpm,
|
|
1207
|
+
step: linear(1, SCORE_LIMITS.minSampleBpm, SCORE_LIMITS.maxSampleBpm),
|
|
1208
|
+
format: num,
|
|
1209
|
+
command: (value) => `/bpm ${num(value)} ${voice}`,
|
|
1210
|
+
reset: `/bpm off ${voice}`,
|
|
1211
|
+
help: SAMPLE_CONTROLS.bpm,
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
kind: "choice",
|
|
1215
|
+
label: "fitmode",
|
|
1216
|
+
// off: unset (plays as repitch); auto suggests one from the sound.
|
|
1217
|
+
value: ref.fitmode ?? "off",
|
|
1218
|
+
options: ["off", ...SAMPLE_FIT_MODES, "auto"],
|
|
1219
|
+
command: (option) => `/fitmode ${option} ${voice}`,
|
|
1220
|
+
help: `${SAMPLE_CONTROLS.fitmode} · beats for drums, tones for pads · set bpm or len first`,
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
kind: "number",
|
|
1224
|
+
label: "len",
|
|
1225
|
+
value: ref.len,
|
|
1226
|
+
start: 4,
|
|
1227
|
+
off: "off",
|
|
1228
|
+
min: 0.25,
|
|
1229
|
+
max: SCORE_LIMITS.maxSampleLenBeats,
|
|
1230
|
+
step: linear(0.25, 0.25, SCORE_LIMITS.maxSampleLenBeats),
|
|
1231
|
+
format: num,
|
|
1232
|
+
command: (value) => `/len ${num(value)} ${voice}`,
|
|
1233
|
+
reset: `/len off ${voice}`,
|
|
1234
|
+
help: SAMPLE_CONTROLS.len,
|
|
1235
|
+
},
|
|
832
1236
|
);
|
|
833
1237
|
return nodes;
|
|
834
1238
|
}
|
|
@@ -845,6 +1249,57 @@ function matchingSynthPreset(track: Track): string | undefined {
|
|
|
845
1249
|
)?.[0];
|
|
846
1250
|
}
|
|
847
1251
|
|
|
1252
|
+
/** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
|
|
1253
|
+
function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
|
|
1254
|
+
const effective = resolvedKeys(track.instrument, track.keys);
|
|
1255
|
+
const nodes: MenuNode[] = [
|
|
1256
|
+
{
|
|
1257
|
+
kind: "choice",
|
|
1258
|
+
label: "preset",
|
|
1259
|
+
value: track.keys?.preset ?? "—",
|
|
1260
|
+
options: Object.keys(KEYS_PRESETS),
|
|
1261
|
+
command: (preset) => `keys preset ${preset}`,
|
|
1262
|
+
help: "a starting piano; every value stays editable",
|
|
1263
|
+
},
|
|
1264
|
+
];
|
|
1265
|
+
for (const key of params) {
|
|
1266
|
+
const param = KEYS_PARAMS[key]!;
|
|
1267
|
+
const stored = track.keys?.[key];
|
|
1268
|
+
if (param.kind === "number") {
|
|
1269
|
+
const base = effective[key] as number;
|
|
1270
|
+
nodes.push({
|
|
1271
|
+
kind: "number",
|
|
1272
|
+
label: key,
|
|
1273
|
+
value: typeof stored === "number" ? stored : undefined,
|
|
1274
|
+
start: base,
|
|
1275
|
+
off: withUnit(formatParam(param, base), param.unit),
|
|
1276
|
+
min: param.min,
|
|
1277
|
+
max: param.max,
|
|
1278
|
+
step: specStep(param),
|
|
1279
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
1280
|
+
command: (value) => `keys ${key} ${formatParam(param, value)}`,
|
|
1281
|
+
reset: `keys ${key} off`,
|
|
1282
|
+
help: param.doc,
|
|
1283
|
+
});
|
|
1284
|
+
} else if (param.kind === "enum")
|
|
1285
|
+
nodes.push({
|
|
1286
|
+
kind: "choice",
|
|
1287
|
+
label: key,
|
|
1288
|
+
value: typeof stored === "string" ? stored : String(effective[key]),
|
|
1289
|
+
options: param.values,
|
|
1290
|
+
command: (option) => `keys ${key} ${option}`,
|
|
1291
|
+
help: param.doc,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
nodes.push({
|
|
1295
|
+
kind: "action",
|
|
1296
|
+
label: "reset to the family",
|
|
1297
|
+
command: "keys reset",
|
|
1298
|
+
help: "clear every override and the preset, and the effects the preset added (keeps the family's own sound)",
|
|
1299
|
+
});
|
|
1300
|
+
return nodes;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
848
1303
|
function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
|
|
849
1304
|
const nodes: MenuNode[] = [];
|
|
850
1305
|
if (keys === SYNTH_SIMPLE)
|
|
@@ -915,6 +1370,57 @@ const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
|
915
1370
|
fm: "FM amount",
|
|
916
1371
|
};
|
|
917
1372
|
|
|
1373
|
+
/** String preset first (simple view), then one row per parameter. */
|
|
1374
|
+
function stringNodes(
|
|
1375
|
+
track: Track,
|
|
1376
|
+
keys: readonly string[],
|
|
1377
|
+
withPreset = true,
|
|
1378
|
+
): MenuNode[] {
|
|
1379
|
+
const nodes: MenuNode[] = [];
|
|
1380
|
+
const preset = STRING_PRESETS[stringPresetOf(track.string)]!;
|
|
1381
|
+
if (withPreset)
|
|
1382
|
+
nodes.push({
|
|
1383
|
+
kind: "choice",
|
|
1384
|
+
label: "preset",
|
|
1385
|
+
help: preset.doc,
|
|
1386
|
+
value: stringPresetOf(track.string),
|
|
1387
|
+
options: STRING_PRESET_NAMES,
|
|
1388
|
+
command: (name) => `string preset ${name}`,
|
|
1389
|
+
});
|
|
1390
|
+
for (const key of keys) {
|
|
1391
|
+
const param = STRING_PARAMS[key]!;
|
|
1392
|
+
const own = track.string?.[key];
|
|
1393
|
+
const base = preset.values[key] ?? param.default;
|
|
1394
|
+
if (param.kind === "number")
|
|
1395
|
+
nodes.push({
|
|
1396
|
+
kind: "number",
|
|
1397
|
+
label: param.unit ? `${key} ${param.unit}` : key,
|
|
1398
|
+
help: param.doc,
|
|
1399
|
+
value: typeof own === "number" ? own : undefined,
|
|
1400
|
+
start: typeof base === "number" ? base : param.default,
|
|
1401
|
+
off: formatParam(
|
|
1402
|
+
param,
|
|
1403
|
+
typeof base === "number" ? base : param.default,
|
|
1404
|
+
),
|
|
1405
|
+
min: param.min,
|
|
1406
|
+
max: param.max,
|
|
1407
|
+
step: specStep(param),
|
|
1408
|
+
format: (value) => formatParam(param, value),
|
|
1409
|
+
command: (value) => `string ${key} ${formatParam(param, value)}`,
|
|
1410
|
+
});
|
|
1411
|
+
else if (param.kind === "enum")
|
|
1412
|
+
nodes.push({
|
|
1413
|
+
kind: "choice",
|
|
1414
|
+
label: key,
|
|
1415
|
+
help: param.doc,
|
|
1416
|
+
value: typeof own === "string" ? own : String(base),
|
|
1417
|
+
options: param.values,
|
|
1418
|
+
command: (option) => `string ${key} ${option}`,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
return nodes;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
918
1424
|
/** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
|
|
919
1425
|
function synthAdvancedNodes(context: MenuContext): MenuNode[] {
|
|
920
1426
|
const track = focused(context);
|
|
@@ -1064,10 +1570,38 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
1064
1570
|
build: (inner) => effectParamNodes(inner, effect, false),
|
|
1065
1571
|
};
|
|
1066
1572
|
};
|
|
1067
|
-
const
|
|
1573
|
+
const rigStages = RIG_STAGES as readonly string[];
|
|
1574
|
+
const more = EFFECT_NAMES.filter(
|
|
1575
|
+
(effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
|
|
1576
|
+
);
|
|
1068
1577
|
const moreOn = more.filter((effect) => effectValues(track, effect));
|
|
1578
|
+
// 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
|
|
1579
|
+
const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
|
|
1580
|
+
const rig: MenuNode = {
|
|
1581
|
+
kind: "menu",
|
|
1582
|
+
id: "guitar rig",
|
|
1583
|
+
label: "Guitar rig",
|
|
1584
|
+
help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
|
|
1585
|
+
detail:
|
|
1586
|
+
rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
|
|
1587
|
+
build: (inner) => {
|
|
1588
|
+
const current = focused(inner);
|
|
1589
|
+
return [
|
|
1590
|
+
{
|
|
1591
|
+
kind: "choice",
|
|
1592
|
+
label: "rig",
|
|
1593
|
+
value: rigPresetOf(current?.fx) ?? "—",
|
|
1594
|
+
options: [...Object.keys(RIG_PRESETS), "reset"],
|
|
1595
|
+
command: (name) => `rig ${name}`,
|
|
1596
|
+
help: "a whole rig (stomp, head, cab); every stage stays editable",
|
|
1597
|
+
},
|
|
1598
|
+
...RIG_STAGES.map(node),
|
|
1599
|
+
];
|
|
1600
|
+
},
|
|
1601
|
+
};
|
|
1069
1602
|
return [
|
|
1070
1603
|
...CORE_EFFECTS.map(node),
|
|
1604
|
+
rig,
|
|
1071
1605
|
{
|
|
1072
1606
|
kind: "menu",
|
|
1073
1607
|
id: "more effects",
|
|
@@ -1235,6 +1769,9 @@ function automationNodes(context: MenuContext): MenuNode[] {
|
|
|
1235
1769
|
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
1236
1770
|
if (info?.effect === "synth")
|
|
1237
1771
|
return track.synth?.[info.param] !== undefined;
|
|
1772
|
+
if (info?.effect === "string") return track.string !== undefined;
|
|
1773
|
+
if (info?.effect === "keys") return track.keys !== undefined;
|
|
1774
|
+
if (info?.effect === "modal") return track.modal !== undefined;
|
|
1238
1775
|
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
1239
1776
|
});
|
|
1240
1777
|
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
@@ -1310,7 +1847,7 @@ function laneNodes(
|
|
|
1310
1847
|
value: "two points",
|
|
1311
1848
|
placeholder: "from-beat:value to-beat:value",
|
|
1312
1849
|
command: (text) => pointsCommand(lane, text, 2),
|
|
1313
|
-
example: `automate ${lane} points 0:${laneFormat(lane, min)} ${context.score
|
|
1850
|
+
example: `automate ${lane} points 0:${laneFormat(lane, min)} ${loopTicksOf(context.score) / context.score.ticksPerBeat}:${laneFormat(lane, max)}`,
|
|
1314
1851
|
},
|
|
1315
1852
|
];
|
|
1316
1853
|
for (const point of points)
|
|
@@ -1416,14 +1953,54 @@ function patternsMenu(): MenuNode {
|
|
|
1416
1953
|
};
|
|
1417
1954
|
}
|
|
1418
1955
|
|
|
1956
|
+
const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
|
|
1957
|
+
|
|
1419
1958
|
function soundNodes(): MenuNode[] {
|
|
1420
1959
|
return [
|
|
1960
|
+
{
|
|
1961
|
+
kind: "menu",
|
|
1962
|
+
id: "group:keys",
|
|
1963
|
+
label: "Keys",
|
|
1964
|
+
help: "modelled pianos, built in (no download)",
|
|
1965
|
+
detail: Object.keys(KEYS_PRESETS).join(" "),
|
|
1966
|
+
build: () =>
|
|
1967
|
+
Object.entries(KEYS_PRESETS).map(([name, preset]): MenuNode => ({
|
|
1968
|
+
kind: "action",
|
|
1969
|
+
label: `${name.padEnd(10)} ${preset.doc}`,
|
|
1970
|
+
command: `piano ${name}`,
|
|
1971
|
+
help: preset.styles,
|
|
1972
|
+
})),
|
|
1973
|
+
},
|
|
1421
1974
|
{
|
|
1422
1975
|
kind: "action",
|
|
1423
1976
|
label: "wavetable synth basic shapes morph · built-in",
|
|
1424
1977
|
command: "wt basic",
|
|
1425
1978
|
help: "switch this track to the morphing wavetable synth",
|
|
1426
1979
|
},
|
|
1980
|
+
// 0.6 instrument groups, one per lane.
|
|
1981
|
+
malletsMenu(),
|
|
1982
|
+
{
|
|
1983
|
+
kind: "menu",
|
|
1984
|
+
id: "strings",
|
|
1985
|
+
label: "Strings",
|
|
1986
|
+
help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…",
|
|
1987
|
+
detail: `${STRING_PRESET_NAMES.length} plucked · built-in`,
|
|
1988
|
+
build: () =>
|
|
1989
|
+
STRING_PRESET_NAMES.map((name): MenuNode => ({
|
|
1990
|
+
kind: "action",
|
|
1991
|
+
label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
|
|
1992
|
+
command: `string ${name}`,
|
|
1993
|
+
help: STRING_PRESETS[name]!.styles,
|
|
1994
|
+
})),
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
kind: "menu",
|
|
1998
|
+
id: "granular-browse",
|
|
1999
|
+
label: "Granular",
|
|
2000
|
+
help: "grain clouds from a built-in synth or this track's own sound",
|
|
2001
|
+
detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
|
|
2002
|
+
build: granularBrowseNodes,
|
|
2003
|
+
},
|
|
1427
2004
|
{
|
|
1428
2005
|
kind: "menu",
|
|
1429
2006
|
id: "instruments",
|
|
@@ -1506,9 +2083,10 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
1506
2083
|
min: 1,
|
|
1507
2084
|
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
1508
2085
|
step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
|
|
1509
|
-
format: (value) => `${value}
|
|
1510
|
-
command: (value) =>
|
|
2086
|
+
format: (value) => `${value}/${openingUnit(score)}`,
|
|
2087
|
+
command: (value) => openingMeterCommand(score, value),
|
|
1511
2088
|
},
|
|
2089
|
+
tempoMenuNode(context),
|
|
1512
2090
|
{
|
|
1513
2091
|
kind: "number",
|
|
1514
2092
|
label: "loop length",
|
|
@@ -1543,9 +2121,226 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
1543
2121
|
options: ["0", "1", "2"],
|
|
1544
2122
|
command: (option) => `/count-in ${option}`,
|
|
1545
2123
|
},
|
|
2124
|
+
{
|
|
2125
|
+
kind: "menu",
|
|
2126
|
+
id: "tuning",
|
|
2127
|
+
label: "tuning & scale",
|
|
2128
|
+
detail: `${tuningLabel(score.tuning)} · ${keyLabel(score.key)}`,
|
|
2129
|
+
help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
|
|
2130
|
+
build: songTuningNodes,
|
|
2131
|
+
},
|
|
1546
2132
|
];
|
|
1547
2133
|
}
|
|
1548
2134
|
|
|
2135
|
+
// ── tuning ────────────────────────────────────────────────────────────
|
|
2136
|
+
|
|
2137
|
+
/** Short name of a tuning for a choice row: its library name or table. */
|
|
2138
|
+
function tuningLabel(tuning: Tuning | undefined): string {
|
|
2139
|
+
if (!tuning) return "12-tet";
|
|
2140
|
+
if (tuning.name) return tuning.name;
|
|
2141
|
+
if (tuning.edo) return `${tuning.edo}-edo`;
|
|
2142
|
+
if (tuning.scl) return tuning.scl.split("/").at(-1)!;
|
|
2143
|
+
if (tuning.ratios || tuning.cents) return "custom";
|
|
2144
|
+
return "12-tet";
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function keyLabel(key: string | null | undefined): string {
|
|
2148
|
+
const parsed = parseKey(key ?? undefined);
|
|
2149
|
+
return parsed ? keyName(parsed) : "no key";
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
const ROOT_OPTIONS = [
|
|
2153
|
+
"auto",
|
|
2154
|
+
"C4",
|
|
2155
|
+
"Db4",
|
|
2156
|
+
"D4",
|
|
2157
|
+
"Eb4",
|
|
2158
|
+
"E4",
|
|
2159
|
+
"F4",
|
|
2160
|
+
"Gb4",
|
|
2161
|
+
"G4",
|
|
2162
|
+
"Ab4",
|
|
2163
|
+
"A4",
|
|
2164
|
+
"Bb4",
|
|
2165
|
+
"B4",
|
|
2166
|
+
] as const;
|
|
2167
|
+
|
|
2168
|
+
/** Rows shared by the song and track tuning screens. */
|
|
2169
|
+
function tuningRows(
|
|
2170
|
+
tuning: Tuning | undefined,
|
|
2171
|
+
prefix: string,
|
|
2172
|
+
first: MenuNode,
|
|
2173
|
+
song?: Tuning,
|
|
2174
|
+
): MenuNode[] {
|
|
2175
|
+
// A track screen shows the song's ref and root where the track has none.
|
|
2176
|
+
const ref = tuning?.ref ?? song?.ref;
|
|
2177
|
+
const refFrom =
|
|
2178
|
+
tuning?.ref === undefined && ref !== undefined ? " · song" : "";
|
|
2179
|
+
const root = tuning?.root ?? song?.root;
|
|
2180
|
+
const rootFrom =
|
|
2181
|
+
tuning?.root === undefined && root !== undefined ? " · song" : "";
|
|
2182
|
+
const nodes: MenuNode[] = [
|
|
2183
|
+
first,
|
|
2184
|
+
{
|
|
2185
|
+
kind: "info",
|
|
2186
|
+
label: "steps (cents)",
|
|
2187
|
+
value: tuningStepsText(tuning),
|
|
2188
|
+
help: describeTuning(tuning),
|
|
2189
|
+
},
|
|
2190
|
+
{
|
|
2191
|
+
kind: "number",
|
|
2192
|
+
label: "reference A4",
|
|
2193
|
+
help: "12-TET A4 in Hz; the root sounds at its 12-TET pitch from it · x: 440",
|
|
2194
|
+
value: ref,
|
|
2195
|
+
off: "440 Hz",
|
|
2196
|
+
start: 440,
|
|
2197
|
+
min: TUNING_LIMITS.minRefHz,
|
|
2198
|
+
max: TUNING_LIMITS.maxRefHz,
|
|
2199
|
+
step: linear(1, TUNING_LIMITS.minRefHz, TUNING_LIMITS.maxRefHz),
|
|
2200
|
+
format: (value) => `${num(value)} Hz${refFrom}`,
|
|
2201
|
+
command: (value) => `${prefix} ref ${num(value)}`,
|
|
2202
|
+
reset: `${prefix} ref off`,
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
kind: "choice",
|
|
2206
|
+
label: "root",
|
|
2207
|
+
help: song
|
|
2208
|
+
? `the key that plays degree 0; auto follows the song tuning's root${rootFrom ? " (now the song's)" : ""}`
|
|
2209
|
+
: "the key that plays degree 0; auto follows the song key",
|
|
2210
|
+
value: rootName(root),
|
|
2211
|
+
options: ROOT_OPTIONS,
|
|
2212
|
+
command: (option) => `${prefix} root ${option}`,
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
kind: "choice",
|
|
2216
|
+
label: "keys",
|
|
2217
|
+
help: "linear: one key per step (Scala) · nearest: each of 12 keys to its nearest step",
|
|
2218
|
+
value: tuning?.map ?? "linear",
|
|
2219
|
+
options: TUNING_MAPS,
|
|
2220
|
+
command: (option) => `${prefix} map ${option}`,
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
kind: "entry",
|
|
2224
|
+
label: "equal steps",
|
|
2225
|
+
value: tuning?.edo ? String(tuning.edo) : "",
|
|
2226
|
+
placeholder: "steps per octave, e.g. 22",
|
|
2227
|
+
command: (text) =>
|
|
2228
|
+
/^\d+$/.test(text.trim()) ? `${prefix} edo ${text.trim()}` : undefined,
|
|
2229
|
+
example: `${prefix} edo 22`,
|
|
2230
|
+
help: "n equal divisions of the octave",
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
kind: "entry",
|
|
2234
|
+
label: "Scala file",
|
|
2235
|
+
value: tuning?.scl ?? "",
|
|
2236
|
+
placeholder: "path to a .scl file",
|
|
2237
|
+
command: (text) =>
|
|
2238
|
+
text.trim() ? `${prefix} scl ${text.trim()}` : undefined,
|
|
2239
|
+
example: `${prefix} scl tunings/meantone.scl`,
|
|
2240
|
+
help: "a Scala scale; files outside the project are copied into tunings/",
|
|
2241
|
+
},
|
|
2242
|
+
{
|
|
2243
|
+
kind: "entry",
|
|
2244
|
+
label: "ratios",
|
|
2245
|
+
value: tuning?.ratios?.join(" ") ?? "",
|
|
2246
|
+
placeholder: "just ratios, the last is the period",
|
|
2247
|
+
command: (text) =>
|
|
2248
|
+
text.trim() ? `${prefix} ratios ${text.trim()}` : undefined,
|
|
2249
|
+
example: `${prefix} ratios 9/8 5/4 3/2 2/1`,
|
|
2250
|
+
help: "a just-intonation table: ratios for degrees 1..n",
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
kind: "entry",
|
|
2254
|
+
label: "cents",
|
|
2255
|
+
value: tuning?.cents && !tuning.scl ? tuning.cents.join(" ") : "",
|
|
2256
|
+
placeholder: "cents, the last is the period",
|
|
2257
|
+
command: (text) =>
|
|
2258
|
+
/^[-+\d.\s]+$/.test(text.trim())
|
|
2259
|
+
? `${prefix} cents ${text.trim()}`
|
|
2260
|
+
: undefined,
|
|
2261
|
+
example: `${prefix} cents 231 474 717 955 1200`,
|
|
2262
|
+
help: "a table in cents for degrees 1..n",
|
|
2263
|
+
},
|
|
2264
|
+
{
|
|
2265
|
+
kind: "entry",
|
|
2266
|
+
label: "keyboard map (.kbm)",
|
|
2267
|
+
value: tuning?.kbm ?? "",
|
|
2268
|
+
placeholder: "path to a .kbm file, or off",
|
|
2269
|
+
command: (text) =>
|
|
2270
|
+
text.trim() ? `${prefix} kbm ${text.trim()}` : undefined,
|
|
2271
|
+
example: `${prefix} kbm tunings/white.kbm`,
|
|
2272
|
+
help: "a Scala keyboard map: which key plays which degree; off removes it",
|
|
2273
|
+
},
|
|
2274
|
+
];
|
|
2275
|
+
return nodes;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/** Project › tuning & scale: the song tuning and the song scale. */
|
|
2279
|
+
function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
2280
|
+
const score = context.score;
|
|
2281
|
+
const key = parseKey(score.key ?? undefined);
|
|
2282
|
+
const scale = key ? (key.scale ?? key.mode) : "major";
|
|
2283
|
+
const tonic = key ? keyName(key).split(" ")[0]! : "C";
|
|
2284
|
+
return [
|
|
2285
|
+
...tuningRows(score.tuning, "tuning", {
|
|
2286
|
+
kind: "choice",
|
|
2287
|
+
label: "tuning",
|
|
2288
|
+
help: "the song tuning; every track without its own follows it",
|
|
2289
|
+
value: tuningLabel(score.tuning),
|
|
2290
|
+
options: TUNING_NAMES,
|
|
2291
|
+
command: (option) =>
|
|
2292
|
+
option === "12-tet" ? "tuning off" : `tuning ${option}`,
|
|
2293
|
+
}),
|
|
2294
|
+
{
|
|
2295
|
+
kind: "choice",
|
|
2296
|
+
label: "scale",
|
|
2297
|
+
help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
|
|
2298
|
+
value: scale,
|
|
2299
|
+
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
2300
|
+
command: (option) => `scale ${option}`,
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
kind: "choice",
|
|
2304
|
+
label: "tonic",
|
|
2305
|
+
help: "the song key's tonic; keeps the scale",
|
|
2306
|
+
value: tonic,
|
|
2307
|
+
options: TONICS,
|
|
2308
|
+
command: (option) => `scale ${option} ${scale}`,
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
kind: "action",
|
|
2312
|
+
label: "list tunings",
|
|
2313
|
+
command: "tuning list",
|
|
2314
|
+
help: "every library tuning, by family",
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
kind: "action",
|
|
2318
|
+
label: "list scales",
|
|
2319
|
+
command: "scale list",
|
|
2320
|
+
help: "every mode and scale, by family",
|
|
2321
|
+
},
|
|
2322
|
+
];
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** Sound › tuning: the focused track's own tuning, or the song's. */
|
|
2326
|
+
function trackTuningNodes(context: MenuContext): MenuNode[] {
|
|
2327
|
+
const track = focused(context);
|
|
2328
|
+
return tuningRows(
|
|
2329
|
+
track?.tuning,
|
|
2330
|
+
"tuning track",
|
|
2331
|
+
{
|
|
2332
|
+
kind: "choice",
|
|
2333
|
+
label: "tuning",
|
|
2334
|
+
help: "song: follow the song tuning · or a tuning for this track only",
|
|
2335
|
+
value: track?.tuning ? tuningLabel(track.tuning) : "song",
|
|
2336
|
+
options: ["song", ...TUNING_NAMES],
|
|
2337
|
+
command: (option) =>
|
|
2338
|
+
option === "song" ? "tuning track off" : `tuning track ${option}`,
|
|
2339
|
+
},
|
|
2340
|
+
context.score.tuning ?? {},
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1549
2344
|
// ── controller ────────────────────────────────────────────────────────
|
|
1550
2345
|
|
|
1551
2346
|
type Frame = {
|
|
@@ -1639,6 +2434,88 @@ export class EditMenu {
|
|
|
1639
2434
|
}
|
|
1640
2435
|
}
|
|
1641
2436
|
|
|
2437
|
+
/**
|
|
2438
|
+
* Open on the level a bare parameter command edits, for the fader drawer:
|
|
2439
|
+
* `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
|
|
2440
|
+
* in Effects or its "more effects" list). Returns the label to focus, or
|
|
2441
|
+
* undefined when `command` names no such parameter (the menu is closed).
|
|
2442
|
+
*/
|
|
2443
|
+
showFader(context: MenuContext, command: string): string | undefined {
|
|
2444
|
+
const words = command.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
|
|
2445
|
+
if (words.length === 1 && (words[0] === "volume" || words[0] === "pan")) {
|
|
2446
|
+
this.show(context, "mix");
|
|
2447
|
+
if (this.stack.length < 2) return this.fail();
|
|
2448
|
+
return this.focusLabel(context, words[0]) ?? this.fail();
|
|
2449
|
+
}
|
|
2450
|
+
if (words[0] !== "fx" || words.length < 2 || words.length > 3)
|
|
2451
|
+
return undefined;
|
|
2452
|
+
const effect = parseEffectName(words[1]!);
|
|
2453
|
+
if (!effect) return undefined;
|
|
2454
|
+
const param = words[2] ? parseParamName(effect, words[2]) : undefined;
|
|
2455
|
+
if (words[2] && !param) return undefined;
|
|
2456
|
+
this.show(context, "effects");
|
|
2457
|
+
if (this.stack.length < 2) return this.fail();
|
|
2458
|
+
if (!this.descend(context, effect)) {
|
|
2459
|
+
if (
|
|
2460
|
+
!this.descend(context, "more effects") ||
|
|
2461
|
+
!this.descend(context, effect)
|
|
2462
|
+
)
|
|
2463
|
+
return this.fail();
|
|
2464
|
+
}
|
|
2465
|
+
const fields = this.faderFields(context);
|
|
2466
|
+
const label = param
|
|
2467
|
+
? fields.find((field) => field.label === param)?.label
|
|
2468
|
+
: fields.find((field) => field.kind === "number")?.label;
|
|
2469
|
+
if (!label) return this.fail();
|
|
2470
|
+
return this.focusLabel(context, label) ?? this.fail();
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
private fail(): undefined {
|
|
2474
|
+
this.close();
|
|
2475
|
+
return undefined;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** Step into the child menu `id` of the current level. */
|
|
2479
|
+
private descend(context: MenuContext, id: string): boolean {
|
|
2480
|
+
const frame = this.stack.at(-1);
|
|
2481
|
+
if (!frame) return false;
|
|
2482
|
+
const nodes = frame.build(context);
|
|
2483
|
+
const index = nodes.findIndex(
|
|
2484
|
+
(node) => node.kind === "menu" && node.id === id,
|
|
2485
|
+
);
|
|
2486
|
+
const node = nodes[index];
|
|
2487
|
+
if (node?.kind !== "menu") return false;
|
|
2488
|
+
frame.index = index;
|
|
2489
|
+
this.stack.push(childFrame(frame, node));
|
|
2490
|
+
return true;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/** Put the cursor on the row labelled `label`; returns it when found. */
|
|
2494
|
+
private focusLabel(context: MenuContext, label: string): string | undefined {
|
|
2495
|
+
const frame = this.stack.at(-1);
|
|
2496
|
+
if (!frame) return undefined;
|
|
2497
|
+
const index = frame
|
|
2498
|
+
.build(context)
|
|
2499
|
+
.findIndex((node) => node.label === label);
|
|
2500
|
+
if (index < 0) return undefined;
|
|
2501
|
+
frame.index = index;
|
|
2502
|
+
return label;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/** Select row `index` of the current level (a click), clearing typing. */
|
|
2506
|
+
select(context: MenuContext, index: number): void {
|
|
2507
|
+
const frame = this.stack.at(-1);
|
|
2508
|
+
if (!frame) return;
|
|
2509
|
+
this.entry = undefined;
|
|
2510
|
+
this.filtering = false;
|
|
2511
|
+
frame.index = clamp(index, 0, Math.max(0, this.nodes(context).length - 1));
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/** The row index under the cursor (the picker's). */
|
|
2515
|
+
get index(): number {
|
|
2516
|
+
return this.stack.at(-1)?.index ?? 0;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
1642
2519
|
close(): void {
|
|
1643
2520
|
this.stack = [];
|
|
1644
2521
|
this.filtering = false;
|
|
@@ -1812,20 +2689,23 @@ export class EditMenu {
|
|
|
1812
2689
|
candidate.kind === "choice" &&
|
|
1813
2690
|
candidate.label === node.label,
|
|
1814
2691
|
) ?? node;
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2692
|
+
// The current value is shown, not re-run: re-confirming a
|
|
2693
|
+
// legacy `piano` would otherwise re-voice it as the grand.
|
|
2694
|
+
return live.options.map((option): MenuNode =>
|
|
2695
|
+
option === live.value
|
|
2696
|
+
? { kind: "info", label: `${option} ✓`, value: "current" }
|
|
2697
|
+
: {
|
|
2698
|
+
kind: "action",
|
|
2699
|
+
label: option,
|
|
2700
|
+
command: live.command(option),
|
|
2701
|
+
},
|
|
2702
|
+
);
|
|
1820
2703
|
},
|
|
1821
2704
|
});
|
|
1822
2705
|
return { type: "handled" };
|
|
1823
2706
|
}
|
|
1824
|
-
if (
|
|
1825
|
-
|
|
1826
|
-
node.kind === "number" ||
|
|
1827
|
-
node.kind === "point"
|
|
1828
|
-
) {
|
|
2707
|
+
if (node.kind === "number") return { type: "fader", label: node.label };
|
|
2708
|
+
if (node.kind === "entry" || node.kind === "point") {
|
|
1829
2709
|
this.entry = { label: node.label, buffer: "" };
|
|
1830
2710
|
return { type: "handled" };
|
|
1831
2711
|
}
|
|
@@ -1926,6 +2806,33 @@ export class EditMenu {
|
|
|
1926
2806
|
return { type: "handled" };
|
|
1927
2807
|
}
|
|
1928
2808
|
|
|
2809
|
+
/**
|
|
2810
|
+
* The current level's number and choice rows as fader fields (the drawer's
|
|
2811
|
+
* related params: every filter control, every reverb control), built
|
|
2812
|
+
* from `context` (the staged score while auditioning).
|
|
2813
|
+
*/
|
|
2814
|
+
faderFields(context: MenuContext): FaderSpec[] {
|
|
2815
|
+
const frame = this.stack.at(-1);
|
|
2816
|
+
if (!frame) return [];
|
|
2817
|
+
return faderSpecs(frame.build(context));
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/** The same fields as committed, for the drawer's `staged ← committed`. */
|
|
2821
|
+
faderCommitted(context: MenuContext): FaderSpec[] {
|
|
2822
|
+
const audition = context.audition;
|
|
2823
|
+
if (!audition?.dirty) return this.faderFields(context);
|
|
2824
|
+
return this.faderFields({
|
|
2825
|
+
...context,
|
|
2826
|
+
score: audition.committed,
|
|
2827
|
+
...(audition.committedChords ? { chords: audition.committedChords } : {}),
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
/** The breadcrumb, for the drawer's title. */
|
|
2832
|
+
get crumbs(): string {
|
|
2833
|
+
return this.stack.map((level) => level.title).join(" › ");
|
|
2834
|
+
}
|
|
2835
|
+
|
|
1929
2836
|
/** The picker the TUI draws for the current level. */
|
|
1930
2837
|
view(context: MenuContext): MenuView {
|
|
1931
2838
|
const frame = this.stack.at(-1);
|
|
@@ -2023,6 +2930,37 @@ function placeholderFor(node: MenuNode | undefined): string {
|
|
|
2023
2930
|
return "";
|
|
2024
2931
|
}
|
|
2025
2932
|
|
|
2933
|
+
/** Menu rows → fader fields: numbers and choices, in row order. */
|
|
2934
|
+
export function faderSpecs(nodes: readonly MenuNode[]): FaderSpec[] {
|
|
2935
|
+
const fields: FaderSpec[] = [];
|
|
2936
|
+
for (const node of nodes) {
|
|
2937
|
+
if (node.kind === "number")
|
|
2938
|
+
fields.push({
|
|
2939
|
+
kind: "number",
|
|
2940
|
+
label: node.label,
|
|
2941
|
+
value: node.value,
|
|
2942
|
+
min: node.min,
|
|
2943
|
+
max: node.max,
|
|
2944
|
+
step: node.step,
|
|
2945
|
+
format: node.format,
|
|
2946
|
+
command: node.command,
|
|
2947
|
+
parse: (text) => entryCommand(node, text),
|
|
2948
|
+
reset: node.reset,
|
|
2949
|
+
start: node.start,
|
|
2950
|
+
off: node.off,
|
|
2951
|
+
});
|
|
2952
|
+
else if (node.kind === "choice")
|
|
2953
|
+
fields.push({
|
|
2954
|
+
kind: "choice",
|
|
2955
|
+
label: node.label,
|
|
2956
|
+
value: node.value,
|
|
2957
|
+
options: node.options,
|
|
2958
|
+
command: node.command,
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
return fields;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2026
2964
|
function entryCommand(node: MenuNode, text: string): string | undefined {
|
|
2027
2965
|
const trimmed = text.trim();
|
|
2028
2966
|
if (node.kind === "entry") return node.command(trimmed);
|
|
@@ -2134,11 +3072,17 @@ export const MENU_SECTIONS = [
|
|
|
2134
3072
|
"rhythm",
|
|
2135
3073
|
"chords",
|
|
2136
3074
|
"mix",
|
|
3075
|
+
"master",
|
|
2137
3076
|
"project",
|
|
3077
|
+
"arrange",
|
|
2138
3078
|
// Older names, still accepted.
|
|
2139
3079
|
"parameters",
|
|
2140
3080
|
"sounds",
|
|
2141
3081
|
"track",
|
|
2142
3082
|
"automation",
|
|
2143
3083
|
"transport",
|
|
3084
|
+
// Subsections.
|
|
3085
|
+
"tempo",
|
|
3086
|
+
"meter",
|
|
3087
|
+
"time",
|
|
2144
3088
|
] as const;
|