@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -12
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/tui/menu.ts
CHANGED
|
@@ -8,18 +8,47 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Rendering reuses the TUI's picker overlay: `view()` returns a picker.
|
|
10
10
|
*/
|
|
11
|
+
import { TOPIC_ALIASES } from "../lang/glossary.ts";
|
|
12
|
+
import {
|
|
13
|
+
voiceEffectRows,
|
|
14
|
+
voiceRootDetail,
|
|
15
|
+
voiceRootNodes,
|
|
16
|
+
} from "./menu-voice.ts";
|
|
11
17
|
import { commandParam, sketchFor } from "./sketch.ts";
|
|
12
|
-
import {
|
|
18
|
+
import { nearest } from "../commands/nearest.ts";
|
|
19
|
+
import {
|
|
20
|
+
ARROW_DOWN,
|
|
21
|
+
ARROW_UP,
|
|
22
|
+
HINTS,
|
|
23
|
+
KEY_BACK,
|
|
24
|
+
KEY_BACKSPACE,
|
|
25
|
+
KEY_DOWN,
|
|
26
|
+
KEY_END,
|
|
27
|
+
KEY_ENTER,
|
|
28
|
+
KEY_FORWARD,
|
|
29
|
+
KEY_HOME,
|
|
30
|
+
KEY_LEFT,
|
|
31
|
+
KEY_PAGE_DOWN,
|
|
32
|
+
KEY_PAGE_UP,
|
|
33
|
+
KEY_RESET,
|
|
34
|
+
KEY_RIGHT,
|
|
35
|
+
KEY_UP,
|
|
36
|
+
} from "../../tui/grammar.ts";
|
|
13
37
|
import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
|
|
14
38
|
import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
|
|
15
39
|
import { performanceDetail, performanceNodes } from "./performance-menu.ts";
|
|
16
40
|
import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
|
|
41
|
+
import { windParameterNodes, windsMenu } from "./wind-menu.ts";
|
|
42
|
+
import { singParameterNodes } from "./sing-menu.ts";
|
|
17
43
|
import type { FaderSpec } from "./fader.ts";
|
|
18
44
|
import {
|
|
19
45
|
openingMeterCommand,
|
|
20
46
|
openingUnit,
|
|
21
47
|
tempoDetail,
|
|
22
48
|
tempoMenuNode,
|
|
49
|
+
START_BARS,
|
|
50
|
+
START_BEATS_PER_BAR,
|
|
51
|
+
START_TEMPO_BPM,
|
|
23
52
|
} from "./menu-time.ts";
|
|
24
53
|
import {
|
|
25
54
|
AUTOMATION_PARAMETERS,
|
|
@@ -39,6 +68,8 @@ import {
|
|
|
39
68
|
type Track,
|
|
40
69
|
type TrackAutomationParameter,
|
|
41
70
|
type TrackScore,
|
|
71
|
+
CALIBRATION_LATEST,
|
|
72
|
+
createScore,
|
|
42
73
|
} from "../../core/score.ts";
|
|
43
74
|
import {
|
|
44
75
|
CORE_EFFECTS,
|
|
@@ -47,6 +78,8 @@ import {
|
|
|
47
78
|
RIG_PRESETS,
|
|
48
79
|
RIG_STAGES,
|
|
49
80
|
rigPresetOf,
|
|
81
|
+
SHOEGAZE_EFFECTS,
|
|
82
|
+
VOWEL_VALUES,
|
|
50
83
|
effectPresetNames,
|
|
51
84
|
effectSpec,
|
|
52
85
|
type EffectName,
|
|
@@ -95,16 +128,29 @@ import {
|
|
|
95
128
|
STRING_PARAMS,
|
|
96
129
|
STRING_PRESET_NAMES,
|
|
97
130
|
STRING_PRESETS,
|
|
131
|
+
BOWED_PRESET_NAMES,
|
|
132
|
+
BOW_SIMPLE_PARAMS,
|
|
133
|
+
resolveString,
|
|
98
134
|
STRING_SIMPLE_PARAMS,
|
|
99
135
|
stringPresetOf,
|
|
100
136
|
} from "../../core/strings.ts";
|
|
101
137
|
import {
|
|
102
138
|
KEYS_PARAMS,
|
|
103
139
|
KEYS_PRESETS,
|
|
104
|
-
|
|
105
|
-
|
|
140
|
+
KEYS_FAMILIES,
|
|
141
|
+
ORGAN_ROWS,
|
|
142
|
+
ORGAN_TEXT,
|
|
143
|
+
PIPE_REGISTRATIONS,
|
|
144
|
+
PIPE_STOPS,
|
|
145
|
+
isElectricFamily,
|
|
146
|
+
isKeysFamily,
|
|
147
|
+
isOrganFamily,
|
|
106
148
|
isPianoFamily,
|
|
149
|
+
keysParamsFor,
|
|
150
|
+
keysSimpleFor,
|
|
151
|
+
pipeStops,
|
|
107
152
|
resolvedKeys,
|
|
153
|
+
type OrganFamily,
|
|
108
154
|
} from "../../core/keys.ts";
|
|
109
155
|
import type { PickerItem } from "../../tui/app.ts";
|
|
110
156
|
import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
|
|
@@ -124,6 +170,8 @@ import {
|
|
|
124
170
|
PROGRESSION_STYLES,
|
|
125
171
|
SCALE_NAMES,
|
|
126
172
|
SPREADS,
|
|
173
|
+
STROKE_PATTERN_NAMES,
|
|
174
|
+
GUITAR_TUNING_NAMES,
|
|
127
175
|
keyName,
|
|
128
176
|
parseKey,
|
|
129
177
|
} from "../../core/chords.ts";
|
|
@@ -135,6 +183,7 @@ import {
|
|
|
135
183
|
type Tuning,
|
|
136
184
|
} from "../../core/tuning.ts";
|
|
137
185
|
import { rootName, tuningStepsText } from "../commands/tuning.ts";
|
|
186
|
+
import { describeGuitar } from "../commands/strum.ts";
|
|
138
187
|
import {
|
|
139
188
|
granularBrowseNodes,
|
|
140
189
|
granularMenuDetail,
|
|
@@ -144,6 +193,7 @@ import {
|
|
|
144
193
|
import {
|
|
145
194
|
ARP_RATES,
|
|
146
195
|
CHORD_MODES,
|
|
196
|
+
STROKE_SPEED_MS,
|
|
147
197
|
defaultChordSettings,
|
|
148
198
|
type ChordSettings,
|
|
149
199
|
} from "./play-chords.ts";
|
|
@@ -157,6 +207,10 @@ export type MenuContext = Readonly<{
|
|
|
157
207
|
grids: readonly string[];
|
|
158
208
|
clickOn: boolean;
|
|
159
209
|
countInBars: number;
|
|
210
|
+
/** How much agent turns show (`/showme`); absent hides the row. */
|
|
211
|
+
showMe?: string;
|
|
212
|
+
/** The session's name, for Project › session and export file names. */
|
|
213
|
+
sessionName?: string;
|
|
160
214
|
/** Play mode's chord settings (defaults when absent). */
|
|
161
215
|
chords?: ChordSettings;
|
|
162
216
|
/** Project root, for the project's own wavetables (none when absent). */
|
|
@@ -174,6 +228,8 @@ export type MenuAudition = Readonly<{
|
|
|
174
228
|
looping: boolean;
|
|
175
229
|
/** Edits are staged (an Enter keeps them, Esc reverts them). */
|
|
176
230
|
dirty: boolean;
|
|
231
|
+
/** How many edits are staged, for the drawer's A/B badge. */
|
|
232
|
+
staged?: number;
|
|
177
233
|
/** The committed score, for `staged ← committed` on changed rows. */
|
|
178
234
|
committed: TrackScore;
|
|
179
235
|
/** The footer while auditioning. */
|
|
@@ -342,7 +398,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
|
342
398
|
resonance: "filter resonance",
|
|
343
399
|
"delay-feedback": "delay feedback",
|
|
344
400
|
"delay-mix": "delay mix",
|
|
345
|
-
wt: "
|
|
401
|
+
wt: "table position",
|
|
346
402
|
};
|
|
347
403
|
|
|
348
404
|
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
@@ -384,7 +440,11 @@ function laneLabel(lane: AutomationParameter): string {
|
|
|
384
440
|
info.effect === "synth" ||
|
|
385
441
|
info.effect === "string" ||
|
|
386
442
|
info.effect === "keys" ||
|
|
387
|
-
info.effect === "modal"
|
|
443
|
+
info.effect === "modal" ||
|
|
444
|
+
info.effect === "grain" ||
|
|
445
|
+
info.effect === "wind" ||
|
|
446
|
+
info.effect === "sing" ||
|
|
447
|
+
info.effect === "vocoder"
|
|
388
448
|
? info.effect
|
|
389
449
|
: effectSpec(info.effect).label;
|
|
390
450
|
return `${owner} ${info.param}${unit}`;
|
|
@@ -418,9 +478,17 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
418
478
|
id: "sound",
|
|
419
479
|
label: "Sound",
|
|
420
480
|
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
421
|
-
help: "the focused track's instrument and
|
|
481
|
+
help: "the focused track's instrument, its controls and performance",
|
|
422
482
|
build: soundSectionNodes,
|
|
423
483
|
},
|
|
484
|
+
{
|
|
485
|
+
kind: "menu",
|
|
486
|
+
id: "voice",
|
|
487
|
+
label: "Voice",
|
|
488
|
+
detail: voiceRootDetail(context),
|
|
489
|
+
help: "sing, lyrics, clips, pitch, autotune, formant and vocoder",
|
|
490
|
+
build: voiceRootNodes,
|
|
491
|
+
},
|
|
424
492
|
{
|
|
425
493
|
kind: "menu",
|
|
426
494
|
id: "effects",
|
|
@@ -433,26 +501,34 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
433
501
|
kind: "menu",
|
|
434
502
|
id: "rhythm",
|
|
435
503
|
label: "Rhythm",
|
|
436
|
-
detail: "euclid editor ·
|
|
437
|
-
help: "
|
|
504
|
+
detail: "euclid editor · grooves · kits",
|
|
505
|
+
help: "hits per drum, ready-made grooves and kits",
|
|
438
506
|
build: rhythmNodes,
|
|
439
507
|
},
|
|
440
508
|
{
|
|
441
509
|
kind: "menu",
|
|
442
510
|
id: "chords",
|
|
443
|
-
label: "Chords",
|
|
511
|
+
label: "Chords and key",
|
|
444
512
|
detail: chordsDetail(context),
|
|
445
|
-
help: "song key and how chords play in play mode (Ctrl-P)",
|
|
513
|
+
help: "the song key and tuning, and how chords play in play mode (Ctrl-P)",
|
|
446
514
|
build: chordNodes,
|
|
447
515
|
},
|
|
448
516
|
{
|
|
449
517
|
kind: "menu",
|
|
450
518
|
id: "mix",
|
|
451
|
-
label: "Mix
|
|
519
|
+
label: "Mix",
|
|
452
520
|
detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
|
|
453
|
-
help: "volume, pan, mute and solo per track
|
|
521
|
+
help: "volume, pan, mute and solo per track, automation and the master",
|
|
454
522
|
build: mixSectionNodes,
|
|
455
523
|
},
|
|
524
|
+
{
|
|
525
|
+
kind: "menu",
|
|
526
|
+
id: "arrange",
|
|
527
|
+
label: "Arrange",
|
|
528
|
+
detail: arrangeDetail(context),
|
|
529
|
+
help: "tracks, song sections, the form, builds, drops, fills and styles",
|
|
530
|
+
build: arrangeRootNodes,
|
|
531
|
+
},
|
|
456
532
|
{
|
|
457
533
|
kind: "menu",
|
|
458
534
|
id: "project",
|
|
@@ -460,47 +536,92 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
460
536
|
detail: context.score.time
|
|
461
537
|
? `${tempoDetail(context.score)} · ${context.score.bars} bars`
|
|
462
538
|
: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
|
|
463
|
-
help: "tempo, meter,
|
|
539
|
+
help: "play, tempo, meter, loop, click, export, session, agent and help",
|
|
464
540
|
build: transportNodes,
|
|
465
541
|
},
|
|
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
|
-
},
|
|
474
542
|
];
|
|
475
543
|
}
|
|
476
544
|
|
|
477
|
-
/**
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Where each `/menu <id>` opens: the ten topic ids (sound voice effects
|
|
547
|
+
* rhythm chords mix arrange project keys agent) first, then every older id,
|
|
548
|
+
* as a path of menu ids from the root. `keys` opens Project › help and
|
|
549
|
+
* guides; the ? panel itself belongs to the window.
|
|
550
|
+
*/
|
|
551
|
+
export const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> =
|
|
552
|
+
Object.freeze(
|
|
553
|
+
withTopicAliases({
|
|
554
|
+
sound: ["sound"],
|
|
555
|
+
voice: ["voice"],
|
|
556
|
+
effects: ["effects"],
|
|
557
|
+
rhythm: ["rhythm"],
|
|
558
|
+
chords: ["chords"],
|
|
559
|
+
mix: ["mix"],
|
|
560
|
+
arrange: ["arrange"],
|
|
561
|
+
project: ["project"],
|
|
562
|
+
keys: ["project", "help"],
|
|
563
|
+
agent: ["project", "agent"],
|
|
564
|
+
// Older ids, kept as aliases.
|
|
565
|
+
parameters: ["sound"],
|
|
566
|
+
sounds: ["sound", "browse"],
|
|
567
|
+
instruments: ["sound", "browse"],
|
|
568
|
+
performance: ["sound", "performance"],
|
|
569
|
+
expression: ["sound", "performance"],
|
|
570
|
+
clips: ["voice"],
|
|
571
|
+
lyrics: ["voice"],
|
|
572
|
+
autotune: ["voice"],
|
|
573
|
+
sing: ["voice"],
|
|
574
|
+
vocoder: ["voice"],
|
|
575
|
+
formant: ["voice"],
|
|
576
|
+
grooves: ["rhythm", "patterns"],
|
|
577
|
+
groove: ["rhythm", "patterns"],
|
|
578
|
+
patterns: ["rhythm", "patterns"],
|
|
579
|
+
kits: ["rhythm", "kits"],
|
|
580
|
+
euclid: ["rhythm"],
|
|
581
|
+
tuning: ["chords", "tuning"],
|
|
582
|
+
scale: ["chords"],
|
|
583
|
+
key: ["chords"],
|
|
584
|
+
track: ["mix"],
|
|
585
|
+
automation: ["mix", "automation"],
|
|
586
|
+
master: ["mix", "master"],
|
|
587
|
+
tracks: ["arrange", "tracks"],
|
|
588
|
+
music: ["arrange"],
|
|
589
|
+
style: ["arrange", "style"],
|
|
590
|
+
styles: ["arrange", "style"],
|
|
591
|
+
genre: ["arrange", "style"],
|
|
592
|
+
sections: ["arrange", "sections"],
|
|
593
|
+
form: ["arrange"],
|
|
594
|
+
transport: ["project"],
|
|
595
|
+
tempo: ["project", "tempo"],
|
|
596
|
+
meter: ["project", "tempo"],
|
|
597
|
+
time: ["project", "tempo"],
|
|
598
|
+
export: ["project", "export"],
|
|
599
|
+
session: ["project", "session"],
|
|
600
|
+
sessions: ["project", "session"],
|
|
601
|
+
window: ["project", "help"],
|
|
602
|
+
help: ["project", "help"],
|
|
603
|
+
guides: ["project", "help"],
|
|
604
|
+
model: ["project", "agent"],
|
|
605
|
+
showme: ["project", "agent"],
|
|
606
|
+
models: ["project", "agent"],
|
|
607
|
+
fx: ["effects"],
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Every glossary topic alias (`/help drums`, `/guide mixer`) opens the same
|
|
613
|
+
* topic in `/menu` too: a word with no deeper path here opens its topic.
|
|
614
|
+
*/
|
|
615
|
+
function withTopicAliases(
|
|
616
|
+
paths: Record<string, readonly string[]>,
|
|
617
|
+
): Record<string, readonly string[]> {
|
|
618
|
+
const all = { ...paths };
|
|
619
|
+
for (const [word, topic] of Object.entries(TOPIC_ALIASES))
|
|
620
|
+
if (!all[word] && all[topic]) all[word] = all[topic]!;
|
|
621
|
+
return all;
|
|
622
|
+
}
|
|
502
623
|
|
|
503
|
-
/** Sound: instrument and
|
|
624
|
+
/** Sound: the instrument and its controls first, then the instruments. */
|
|
504
625
|
function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
505
626
|
const track = focused(context);
|
|
506
627
|
const tuning: MenuNode[] =
|
|
@@ -509,7 +630,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
509
630
|
{
|
|
510
631
|
kind: "menu",
|
|
511
632
|
id: "track-tuning",
|
|
512
|
-
label: "tuning",
|
|
633
|
+
label: "track tuning",
|
|
513
634
|
detail: track.tuning
|
|
514
635
|
? tuningLabel(track.tuning)
|
|
515
636
|
: `song · ${tuningLabel(context.score.tuning)}`,
|
|
@@ -533,27 +654,62 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
533
654
|
]
|
|
534
655
|
: [];
|
|
535
656
|
const keys: MenuNode[] =
|
|
536
|
-
track &&
|
|
657
|
+
track && isOrganFamily(track.instrument) && track.keys
|
|
537
658
|
? [
|
|
538
659
|
{
|
|
539
660
|
kind: "menu",
|
|
540
661
|
id: "keys",
|
|
541
662
|
label: "keys",
|
|
542
663
|
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
543
|
-
help: "the
|
|
664
|
+
help: "the modeled organ: preset, drawbars, registers or stops, rotary",
|
|
544
665
|
build: (inner) => {
|
|
545
666
|
const current = focused(inner);
|
|
546
|
-
return current
|
|
547
|
-
? keysNodes(current, Object.keys(KEYS_PARAMS))
|
|
548
|
-
: [];
|
|
667
|
+
return current ? organNodes(current) : [];
|
|
549
668
|
},
|
|
550
669
|
},
|
|
551
670
|
]
|
|
671
|
+
: track && isKeysFamily(track.instrument) && track.keys
|
|
672
|
+
? [
|
|
673
|
+
{
|
|
674
|
+
kind: "menu",
|
|
675
|
+
id: "keys",
|
|
676
|
+
label: "keys",
|
|
677
|
+
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
678
|
+
help: isElectricFamily(track.instrument)
|
|
679
|
+
? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
|
|
680
|
+
: "the modeled piano: preset, touch, hammers, dampers, stretch",
|
|
681
|
+
build: (inner) => {
|
|
682
|
+
const current = focused(inner);
|
|
683
|
+
return current
|
|
684
|
+
? keysNodes(current, keysParamsFor(current.instrument))
|
|
685
|
+
: [];
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
]
|
|
689
|
+
: [];
|
|
690
|
+
// 0.6.1: fretting for strum and the guitar perform mode, on guitar-like
|
|
691
|
+
// tracks (a string voice, a rig, or a guitar setup already stored).
|
|
692
|
+
const guitar: MenuNode[] =
|
|
693
|
+
track &&
|
|
694
|
+
(track.guitar ||
|
|
695
|
+
track.string ||
|
|
696
|
+
RIG_STAGES.some((stage) => track.fx?.[stage] !== undefined))
|
|
697
|
+
? [
|
|
698
|
+
{
|
|
699
|
+
kind: "menu",
|
|
700
|
+
id: "guitar",
|
|
701
|
+
label: "guitar",
|
|
702
|
+
detail: describeGuitar(track.guitar),
|
|
703
|
+
help: "tuning, capo, hand stretch, ringing open strings and position for strum and chords perform guitar",
|
|
704
|
+
build: (inner) => guitarNodes(focused(inner)),
|
|
705
|
+
},
|
|
706
|
+
]
|
|
552
707
|
: [];
|
|
553
708
|
return [
|
|
554
709
|
...parameterNodes(context),
|
|
555
710
|
...granular,
|
|
556
711
|
...keys,
|
|
712
|
+
...guitar,
|
|
557
713
|
...tuning,
|
|
558
714
|
{
|
|
559
715
|
kind: "menu",
|
|
@@ -566,29 +722,349 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
566
722
|
{
|
|
567
723
|
kind: "menu",
|
|
568
724
|
id: "browse",
|
|
569
|
-
label: "
|
|
725
|
+
label: "instruments",
|
|
570
726
|
detail: soundsDetail(focused(context)),
|
|
571
|
-
help: "
|
|
727
|
+
help: "every instrument: keys, strings, mallets, winds, granular, wavetables, sample packs",
|
|
572
728
|
build: soundNodes,
|
|
573
729
|
},
|
|
574
730
|
];
|
|
575
731
|
}
|
|
576
732
|
|
|
733
|
+
/** Sound > guitar: Track.guitar rows plus a strum action. */
|
|
734
|
+
function guitarNodes(track: Track | undefined): MenuNode[] {
|
|
735
|
+
if (!track) return [];
|
|
736
|
+
const g = track.guitar;
|
|
737
|
+
const tune = g?.tune ?? "standard";
|
|
738
|
+
const tuneName = typeof tune === "string" ? tune : tune.join(" ");
|
|
739
|
+
const int = (
|
|
740
|
+
label: string,
|
|
741
|
+
field: "capo" | "hand" | "position",
|
|
742
|
+
min: number,
|
|
743
|
+
max: number,
|
|
744
|
+
fallback: number,
|
|
745
|
+
help: string,
|
|
746
|
+
): MenuNode => ({
|
|
747
|
+
kind: "number",
|
|
748
|
+
label,
|
|
749
|
+
help,
|
|
750
|
+
value: g?.[field] ?? fallback,
|
|
751
|
+
min,
|
|
752
|
+
max,
|
|
753
|
+
step: linear(1, min, max),
|
|
754
|
+
format: (value) => num(Math.round(value)),
|
|
755
|
+
command: (value) => `guitar ${field} ${Math.round(value)}`,
|
|
756
|
+
});
|
|
757
|
+
return [
|
|
758
|
+
{
|
|
759
|
+
kind: "choice",
|
|
760
|
+
label: "tune",
|
|
761
|
+
help: "open strings; type guitar tune D A D G A D for your own",
|
|
762
|
+
value: tuneName,
|
|
763
|
+
options: (GUITAR_TUNING_NAMES as readonly string[]).includes(tuneName)
|
|
764
|
+
? GUITAR_TUNING_NAMES
|
|
765
|
+
: [tuneName, ...GUITAR_TUNING_NAMES],
|
|
766
|
+
command: (option) => `guitar tune ${option}`,
|
|
767
|
+
},
|
|
768
|
+
int(
|
|
769
|
+
"capo",
|
|
770
|
+
"capo",
|
|
771
|
+
0,
|
|
772
|
+
12,
|
|
773
|
+
0,
|
|
774
|
+
"capo fret; chord shapes sound this many semitones up",
|
|
775
|
+
),
|
|
776
|
+
int("hand", "hand", 3, 6, 4, "how many frets one hand shape may span"),
|
|
777
|
+
{
|
|
778
|
+
kind: "number",
|
|
779
|
+
label: "ring",
|
|
780
|
+
help: "0 closed shapes .. 1 lets open strings ring",
|
|
781
|
+
value: g?.ring ?? 0.5,
|
|
782
|
+
min: 0,
|
|
783
|
+
max: 1,
|
|
784
|
+
step: linear(0.1, 0, 1),
|
|
785
|
+
format: (value) => num(value),
|
|
786
|
+
command: (value) => `guitar ring ${Math.round(value * 10) / 10}`,
|
|
787
|
+
},
|
|
788
|
+
int(
|
|
789
|
+
"position",
|
|
790
|
+
"position",
|
|
791
|
+
0,
|
|
792
|
+
12,
|
|
793
|
+
0,
|
|
794
|
+
"preferred fret position for voicings",
|
|
795
|
+
),
|
|
796
|
+
{
|
|
797
|
+
kind: "action",
|
|
798
|
+
label: "strum the chords on this track",
|
|
799
|
+
command: "strum",
|
|
800
|
+
help: "replaces block chords with strummed guitar notes",
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
kind: "action",
|
|
804
|
+
label: "reset",
|
|
805
|
+
command: "guitar reset",
|
|
806
|
+
help: "standard tuning, no capo",
|
|
807
|
+
},
|
|
808
|
+
];
|
|
809
|
+
}
|
|
810
|
+
|
|
577
811
|
/** Rhythm: the euclid editor, then grooves and kits. */
|
|
578
|
-
function rhythmNodes(): MenuNode[] {
|
|
812
|
+
function rhythmNodes(context: MenuContext): MenuNode[] {
|
|
579
813
|
return [
|
|
580
814
|
{
|
|
581
815
|
kind: "action",
|
|
582
816
|
label: "euclid editor",
|
|
583
817
|
command: "/euclid",
|
|
584
|
-
help: "hits, steps and rotation per drum
|
|
818
|
+
help: "hits, steps and rotation per drum",
|
|
585
819
|
},
|
|
586
820
|
patternsMenu(),
|
|
587
821
|
kitsMenu(),
|
|
822
|
+
gridNode(context),
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** The record and euclid grid (Rhythm and Project share this row). */
|
|
827
|
+
function gridNode(context: MenuContext): MenuNode {
|
|
828
|
+
return {
|
|
829
|
+
kind: "choice",
|
|
830
|
+
label: "grid",
|
|
831
|
+
help: "the step play-mode recording and the euclid editor snap to",
|
|
832
|
+
value: context.grid,
|
|
833
|
+
options: context.grids,
|
|
834
|
+
command: (option) => `/grid ${option}`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Arrange: the tracks first, then sections, form and styles. */
|
|
839
|
+
function arrangeRootNodes(context: MenuContext): MenuNode[] {
|
|
840
|
+
const count = context.score.tracks.length;
|
|
841
|
+
return [
|
|
842
|
+
{
|
|
843
|
+
kind: "menu",
|
|
844
|
+
id: "tracks",
|
|
845
|
+
label: "tracks",
|
|
846
|
+
detail: `${count} track${count === 1 ? "" : "s"} · add, focus, move, remove`,
|
|
847
|
+
help: "add a track, focus one, rename, reorder or remove it",
|
|
848
|
+
build: tracksNodes,
|
|
849
|
+
},
|
|
850
|
+
...arrangeNodes(context),
|
|
851
|
+
];
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Arrange › tracks: add, focus, rename, move and remove. */
|
|
855
|
+
function tracksNodes(context: MenuContext): MenuNode[] {
|
|
856
|
+
const score = context.score;
|
|
857
|
+
const track = focused(context);
|
|
858
|
+
const nodes: MenuNode[] = [
|
|
859
|
+
{
|
|
860
|
+
kind: "entry",
|
|
861
|
+
label: "add a track",
|
|
862
|
+
value: "",
|
|
863
|
+
placeholder: "name, e.g. bass or piano b",
|
|
864
|
+
example: "/track bass",
|
|
865
|
+
help: "a new track with that name (or focus it when it exists)",
|
|
866
|
+
command: (text) => {
|
|
867
|
+
const name = text.trim().replace(/\s+/g, " ");
|
|
868
|
+
return /^[a-z0-9._ -]{1,64}$/i.test(name)
|
|
869
|
+
? `/track ${name}`
|
|
870
|
+
: undefined;
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
...score.tracks.map((item): MenuNode => ({
|
|
874
|
+
kind: "action",
|
|
875
|
+
label: `${item.id === context.trackId ? "● " : ""}${item.name ?? item.id}`,
|
|
876
|
+
command: `/track ${item.id}`,
|
|
877
|
+
help: `focus ${item.id} · ${item.instrument}`,
|
|
878
|
+
})),
|
|
879
|
+
];
|
|
880
|
+
if (track) {
|
|
881
|
+
nodes.push({
|
|
882
|
+
kind: "entry",
|
|
883
|
+
label: "rename",
|
|
884
|
+
value: track.name,
|
|
885
|
+
placeholder: "new name",
|
|
886
|
+
command: (text) =>
|
|
887
|
+
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
888
|
+
example: `track name ${track.name}`,
|
|
889
|
+
help: "rename the focused track",
|
|
890
|
+
});
|
|
891
|
+
if (score.tracks.length > 1) {
|
|
892
|
+
const index = score.tracks.findIndex((item) => item.id === track.id);
|
|
893
|
+
nodes.push(
|
|
894
|
+
{
|
|
895
|
+
kind: "number",
|
|
896
|
+
label: "position",
|
|
897
|
+
help: "where the focused track sits in the list",
|
|
898
|
+
value: index + 1,
|
|
899
|
+
min: 1,
|
|
900
|
+
max: score.tracks.length,
|
|
901
|
+
step: linear(1, 1, score.tracks.length),
|
|
902
|
+
format: (value) => `${num(value)} of ${score.tracks.length}`,
|
|
903
|
+
command: (value) => `/track move ${track.id} ${Math.round(value)}`,
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
kind: "action",
|
|
907
|
+
label: `remove ${track.id}`,
|
|
908
|
+
command: `/track remove ${track.id}`,
|
|
909
|
+
help: "remove the focused track (^z brings it back)",
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return nodes;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** A file-safe stem for export names: the session's song name or `song`. */
|
|
918
|
+
function exportStem(context: MenuContext): string {
|
|
919
|
+
const stem = (context.sessionName ?? "song")
|
|
920
|
+
.toLowerCase()
|
|
921
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
922
|
+
.replace(/^-+|-+$/g, "")
|
|
923
|
+
.slice(0, 40);
|
|
924
|
+
return stem || "song";
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Project › export: the project file and MIDI; audio renders offline. */
|
|
928
|
+
function exportNodes(context: MenuContext): MenuNode[] {
|
|
929
|
+
const stem = exportStem(context);
|
|
930
|
+
return [
|
|
931
|
+
{
|
|
932
|
+
kind: "entry",
|
|
933
|
+
label: "project file",
|
|
934
|
+
value: "",
|
|
935
|
+
placeholder: `file name, e.g. ${stem}.track.json`,
|
|
936
|
+
example: `/export ${stem}.track.json`,
|
|
937
|
+
help: "the whole song as one .track.json file (open it with /import)",
|
|
938
|
+
command: (text) => {
|
|
939
|
+
const name = text.trim() || `${stem}.track.json`;
|
|
940
|
+
return /^\S+$/.test(name)
|
|
941
|
+
? `/export ${/\.json$/i.test(name) ? name : `${name}.track.json`}`
|
|
942
|
+
: undefined;
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
kind: "entry",
|
|
947
|
+
label: "MIDI",
|
|
948
|
+
value: "",
|
|
949
|
+
placeholder: `file name, e.g. ${stem}.mid`,
|
|
950
|
+
example: `/export ${stem}.mid`,
|
|
951
|
+
help: "notes, tempo and tracks as a standard MIDI file",
|
|
952
|
+
command: (text) => {
|
|
953
|
+
const name = text.trim() || `${stem}.mid`;
|
|
954
|
+
return /^\S+$/.test(name)
|
|
955
|
+
? `/export ${/\.midi?$/i.test(name) ? name : `${name}.mid`}`
|
|
956
|
+
: undefined;
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
kind: "info",
|
|
961
|
+
label: "WAV and stems",
|
|
962
|
+
value: `dawg render ${stem}.wav`,
|
|
963
|
+
help: "audio renders offline in the shell: dawg render out.wav (--stems for one file per track)",
|
|
964
|
+
},
|
|
965
|
+
];
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Project › session: rename, fork and resume (the window's verbs). */
|
|
969
|
+
function sessionNodes(context: MenuContext): MenuNode[] {
|
|
970
|
+
return [
|
|
971
|
+
{
|
|
972
|
+
kind: "entry",
|
|
973
|
+
label: "rename",
|
|
974
|
+
value: context.sessionName ?? "",
|
|
975
|
+
placeholder: "song name (--auto names it for you)",
|
|
976
|
+
example: "/rename night drive",
|
|
977
|
+
help: "name this session; --auto hands naming back to dawg",
|
|
978
|
+
command: (text) => (text.trim() ? `/rename ${text.trim()}` : undefined),
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
kind: "action",
|
|
982
|
+
label: "fork",
|
|
983
|
+
command: "/fork",
|
|
984
|
+
help: "a copy of this session to try something; the original stays",
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
kind: "action",
|
|
988
|
+
label: "resume",
|
|
989
|
+
command: "/resume",
|
|
990
|
+
help: "pick an earlier session in this folder",
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
kind: "action",
|
|
994
|
+
label: "list sessions",
|
|
995
|
+
command: "/sessions",
|
|
996
|
+
help: "every session in this folder, newest first",
|
|
997
|
+
},
|
|
998
|
+
];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Project › agent: the model, show-me and the model key. */
|
|
1002
|
+
function agentNodes(context: MenuContext): MenuNode[] {
|
|
1003
|
+
return [
|
|
1004
|
+
{
|
|
1005
|
+
kind: "action",
|
|
1006
|
+
label: "model",
|
|
1007
|
+
command: "/model",
|
|
1008
|
+
help: "pick the model the agent runs on",
|
|
1009
|
+
},
|
|
1010
|
+
...(context.showMe === undefined
|
|
1011
|
+
? []
|
|
1012
|
+
: [
|
|
1013
|
+
{
|
|
1014
|
+
kind: "choice" as const,
|
|
1015
|
+
label: "show me",
|
|
1016
|
+
help: "agent turns: its commands as ghost text, faders, keys",
|
|
1017
|
+
value: context.showMe,
|
|
1018
|
+
options: ["on", "quiet", "off"],
|
|
1019
|
+
command: (option: string) => `/showme ${option}`,
|
|
1020
|
+
},
|
|
1021
|
+
]),
|
|
1022
|
+
{
|
|
1023
|
+
kind: "action",
|
|
1024
|
+
label: "model key",
|
|
1025
|
+
command: "/model key",
|
|
1026
|
+
help: "add a model key so typed requests reach the agent (dawg works offline without it)",
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
kind: "action",
|
|
1030
|
+
label: "agent help",
|
|
1031
|
+
command: "/help agent",
|
|
1032
|
+
help: "what the agent can do and how its turns show",
|
|
1033
|
+
},
|
|
588
1034
|
];
|
|
589
1035
|
}
|
|
590
1036
|
|
|
591
|
-
/**
|
|
1037
|
+
/** Project › help and guides: the reference, guides and keys. */
|
|
1038
|
+
function helpNodes(): MenuNode[] {
|
|
1039
|
+
return [
|
|
1040
|
+
{
|
|
1041
|
+
kind: "action",
|
|
1042
|
+
label: "help",
|
|
1043
|
+
command: "/help",
|
|
1044
|
+
help: "start here: the ten topics",
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
kind: "action",
|
|
1048
|
+
label: "all commands",
|
|
1049
|
+
command: "/help all",
|
|
1050
|
+
help: "the full command reference",
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
kind: "action",
|
|
1054
|
+
label: "guides",
|
|
1055
|
+
command: "/guide",
|
|
1056
|
+
help: "short walkthroughs, one screen each",
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
kind: "action",
|
|
1060
|
+
label: "keys",
|
|
1061
|
+
command: "/help keys",
|
|
1062
|
+
help: "every key in the window (? shows them too)",
|
|
1063
|
+
},
|
|
1064
|
+
];
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Mix: this track, every track, automation lanes, then the master. */
|
|
592
1068
|
function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
593
1069
|
const track = focused(context);
|
|
594
1070
|
const automated = AUTOMATION_PARAMETERS.filter(
|
|
@@ -625,7 +1101,7 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
625
1101
|
];
|
|
626
1102
|
}
|
|
627
1103
|
|
|
628
|
-
/** Mix
|
|
1104
|
+
/** Mix › master: the loudness target, then each unit in order. */
|
|
629
1105
|
function masterNodes(context: MenuContext): MenuNode[] {
|
|
630
1106
|
const master = context.score.master;
|
|
631
1107
|
// A name only when the LUFS and the limiter ceiling both match it, and no
|
|
@@ -798,7 +1274,7 @@ function chordsDetail(context: MenuContext): string {
|
|
|
798
1274
|
return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
|
|
799
1275
|
}
|
|
800
1276
|
|
|
801
|
-
/**
|
|
1277
|
+
/** Chords and key: the song key and tuning, then play mode's chord settings. */
|
|
802
1278
|
function chordNodes(context: MenuContext): MenuNode[] {
|
|
803
1279
|
const chords = context.chords ?? defaultChordSettings();
|
|
804
1280
|
const key = parseKey(context.score.key ?? undefined);
|
|
@@ -831,11 +1307,20 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
831
1307
|
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
832
1308
|
command: (option) => `key ${tonic} ${option}`,
|
|
833
1309
|
},
|
|
1310
|
+
{
|
|
1311
|
+
kind: "menu",
|
|
1312
|
+
id: "tuning",
|
|
1313
|
+
label: "tuning",
|
|
1314
|
+
detail: `${tuningLabel(context.score.tuning)} · ${keyLabel(context.score.key)}`,
|
|
1315
|
+
help: "the song tuning: 12-TET, EDOs, just, gamelan, Scala, and the scale",
|
|
1316
|
+
build: songTuningNodes,
|
|
1317
|
+
},
|
|
834
1318
|
{
|
|
835
1319
|
kind: "number",
|
|
836
1320
|
label: "voicing",
|
|
837
1321
|
help: "inversion steps up or down (play mode: - =)",
|
|
838
1322
|
value: chords.inversion,
|
|
1323
|
+
start: 0,
|
|
839
1324
|
min: -MAX_VOICING_STEP,
|
|
840
1325
|
max: MAX_VOICING_STEP,
|
|
841
1326
|
step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
|
|
@@ -881,6 +1366,34 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
881
1366
|
options: CHORD_PATTERNS.map((pattern) => pattern.name),
|
|
882
1367
|
command: (option) => `/chords pattern ${option}`,
|
|
883
1368
|
},
|
|
1369
|
+
// Strum grid and speed only act when perform is guitar.
|
|
1370
|
+
...(chords.perform !== "guitar"
|
|
1371
|
+
? []
|
|
1372
|
+
: ([
|
|
1373
|
+
{
|
|
1374
|
+
kind: "choice",
|
|
1375
|
+
label: "strokes",
|
|
1376
|
+
help: "guitar strum grid (D down, U up, d u light, x chuck, - rest); type /chords strokes D-DU-UDU for your own",
|
|
1377
|
+
value: chords.strokes,
|
|
1378
|
+
options: (STROKE_PATTERN_NAMES as readonly string[]).includes(
|
|
1379
|
+
chords.strokes,
|
|
1380
|
+
)
|
|
1381
|
+
? STROKE_PATTERN_NAMES
|
|
1382
|
+
: [chords.strokes, ...STROKE_PATTERN_NAMES],
|
|
1383
|
+
command: (option) => `/chords strokes ${option}`,
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
kind: "number",
|
|
1387
|
+
label: "speed",
|
|
1388
|
+
help: "ms a full down stroke takes (0 hits every string at once); type /chords speed 1/32b to set it in beats",
|
|
1389
|
+
value: chords.speed,
|
|
1390
|
+
min: STROKE_SPEED_MS.min,
|
|
1391
|
+
max: STROKE_SPEED_MS.max,
|
|
1392
|
+
step: linear(1, STROKE_SPEED_MS.min, STROKE_SPEED_MS.max),
|
|
1393
|
+
format: (value) => `${num(value)} ms`,
|
|
1394
|
+
command: (value) => `/chords speed ${Math.round(value)}ms`,
|
|
1395
|
+
},
|
|
1396
|
+
] satisfies MenuNode[])),
|
|
884
1397
|
{
|
|
885
1398
|
kind: "choice",
|
|
886
1399
|
label: "arp rate",
|
|
@@ -894,6 +1407,7 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
894
1407
|
label: "arp octaves",
|
|
895
1408
|
help: "how many octaves an arpeggio climbs",
|
|
896
1409
|
value: chords.octaves,
|
|
1410
|
+
start: 1,
|
|
897
1411
|
min: 1,
|
|
898
1412
|
max: 4,
|
|
899
1413
|
step: linear(1, 1, 4),
|
|
@@ -910,12 +1424,22 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
910
1424
|
},
|
|
911
1425
|
{
|
|
912
1426
|
kind: "choice",
|
|
913
|
-
label: "
|
|
914
|
-
help: "the
|
|
1427
|
+
label: "idiom",
|
|
1428
|
+
help: "the musical idiom n suggests next chords in (pop, jazz, blues…)",
|
|
915
1429
|
value: chords.style,
|
|
916
1430
|
options: PROGRESSION_STYLES,
|
|
917
1431
|
command: (option) => `/chords style ${option}`,
|
|
918
1432
|
},
|
|
1433
|
+
{
|
|
1434
|
+
kind: "entry",
|
|
1435
|
+
label: "progression",
|
|
1436
|
+
value: "",
|
|
1437
|
+
placeholder: "chords, e.g. i7 IV7 each 8",
|
|
1438
|
+
help: "sustained, voice-led block chords on this track in the song key",
|
|
1439
|
+
command: (text) =>
|
|
1440
|
+
text.trim() ? `progression ${text.trim()}` : undefined,
|
|
1441
|
+
example: "progression i7 IV7 each 8",
|
|
1442
|
+
},
|
|
919
1443
|
];
|
|
920
1444
|
}
|
|
921
1445
|
|
|
@@ -969,7 +1493,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
969
1493
|
}
|
|
970
1494
|
|
|
971
1495
|
function instrumentNode(track: Track): MenuNode {
|
|
972
|
-
const options: string[] = [...AVAILABLE_INSTRUMENTS, ...
|
|
1496
|
+
const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
|
|
973
1497
|
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
974
1498
|
return {
|
|
975
1499
|
kind: "choice",
|
|
@@ -1011,7 +1535,7 @@ function panNode(track: Track): MenuNode {
|
|
|
1011
1535
|
: `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
|
|
1012
1536
|
command: (value) => `pan ${num(value)}`,
|
|
1013
1537
|
reset: "pan 0",
|
|
1014
|
-
help: "-1 left … 1 right · x
|
|
1538
|
+
help: "-1 left … 1 right · x centers",
|
|
1015
1539
|
};
|
|
1016
1540
|
}
|
|
1017
1541
|
|
|
@@ -1023,13 +1547,20 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
1023
1547
|
if (isWavetableInstrument(track.instrument))
|
|
1024
1548
|
nodes.push(...wavetableNodes(track, context.projectRoot));
|
|
1025
1549
|
if (isStringTrack(track)) {
|
|
1026
|
-
nodes.push(
|
|
1550
|
+
nodes.push(
|
|
1551
|
+
...stringNodes(
|
|
1552
|
+
track,
|
|
1553
|
+
resolveString(track.string).exciter === "bow"
|
|
1554
|
+
? BOW_SIMPLE_PARAMS
|
|
1555
|
+
: STRING_SIMPLE_PARAMS,
|
|
1556
|
+
),
|
|
1557
|
+
);
|
|
1027
1558
|
nodes.push({
|
|
1028
1559
|
kind: "menu",
|
|
1029
1560
|
id: "string:advanced",
|
|
1030
1561
|
label: "advanced",
|
|
1031
1562
|
detail: `all ${Object.keys(STRING_PARAMS).length} params`,
|
|
1032
|
-
help: "every string parameter: exciter, loss, body, buzz, sympathetics",
|
|
1563
|
+
help: "every string parameter: exciter, loss, body, buzz, sympathetics, bow (pressure, speed, attack, vib, vibdelay, tremhz, sord, dyn)",
|
|
1033
1564
|
build: (inner) => {
|
|
1034
1565
|
const current = focused(inner);
|
|
1035
1566
|
return current
|
|
@@ -1072,22 +1603,40 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
1072
1603
|
label: "add a voice",
|
|
1073
1604
|
value: "/sample <path> [as <voice>]",
|
|
1074
1605
|
});
|
|
1075
|
-
} else if (
|
|
1076
|
-
|
|
1606
|
+
} else if (isOrganFamily(track.instrument) && track.keys) {
|
|
1607
|
+
// f061-organ: preset, Drawbars/Registers/Stops sub-menus, organ rows.
|
|
1608
|
+
nodes.push(...organNodes(track));
|
|
1609
|
+
} else if (isKeysFamily(track.instrument) && track.keys) {
|
|
1610
|
+
nodes.push(...keysNodes(track, keysSimpleFor(track.instrument)));
|
|
1611
|
+
const all = keysParamsFor(track.instrument);
|
|
1077
1612
|
nodes.push({
|
|
1078
1613
|
kind: "menu",
|
|
1079
1614
|
id: "keys:all",
|
|
1080
|
-
label:
|
|
1081
|
-
|
|
1082
|
-
|
|
1615
|
+
label: isElectricFamily(track.instrument)
|
|
1616
|
+
? `all ${track.instrument} params`
|
|
1617
|
+
: "all piano params",
|
|
1618
|
+
detail: `all ${all.length} params`,
|
|
1619
|
+
help: isElectricFamily(track.instrument)
|
|
1620
|
+
? "every electric keys parameter for this family"
|
|
1621
|
+
: "every modeled piano parameter",
|
|
1083
1622
|
build: (inner) => {
|
|
1084
1623
|
const current = focused(inner);
|
|
1085
|
-
return current
|
|
1624
|
+
return current
|
|
1625
|
+
? keysNodes(current, keysParamsFor(current.instrument))
|
|
1626
|
+
: [];
|
|
1086
1627
|
},
|
|
1087
1628
|
});
|
|
1088
1629
|
} else if (track.instrument === "modal") {
|
|
1089
1630
|
// 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
|
|
1090
1631
|
nodes.push(...modalParameterNodes(track));
|
|
1632
|
+
} else if (track.instrument === "wind" && track.wind) {
|
|
1633
|
+
// 0.6.1 wind engine: preset and WIND_PARAMS rows.
|
|
1634
|
+
nodes.push(...windParameterNodes(track));
|
|
1635
|
+
} else if (track.instrument === "sing" && track.sing) {
|
|
1636
|
+
// 0.7 sing engine: preset, SING_PARAMS rows and a Throat sub-menu.
|
|
1637
|
+
nodes.push(
|
|
1638
|
+
...singParameterNodes(track, parseKey(context.score.key)?.tonic),
|
|
1639
|
+
);
|
|
1091
1640
|
} else if (!isDrumInstrument(track.instrument)) {
|
|
1092
1641
|
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
1093
1642
|
nodes.push({
|
|
@@ -1233,10 +1782,103 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
|
1233
1782
|
reset: `/len off ${voice}`,
|
|
1234
1783
|
help: SAMPLE_CONTROLS.len,
|
|
1235
1784
|
},
|
|
1785
|
+
// 0.6.1 shift: pitch without changing length, formants kept or moved,
|
|
1786
|
+
// and the voice's fade in and out.
|
|
1787
|
+
{
|
|
1788
|
+
kind: "number",
|
|
1789
|
+
label: "shift",
|
|
1790
|
+
value: ref.shift,
|
|
1791
|
+
start: 0,
|
|
1792
|
+
off: "off",
|
|
1793
|
+
min: -SCORE_LIMITS.maxSampleShift,
|
|
1794
|
+
max: SCORE_LIMITS.maxSampleShift,
|
|
1795
|
+
step: linear(
|
|
1796
|
+
1,
|
|
1797
|
+
-SCORE_LIMITS.maxSampleShift,
|
|
1798
|
+
SCORE_LIMITS.maxSampleShift,
|
|
1799
|
+
),
|
|
1800
|
+
format: num,
|
|
1801
|
+
command: (value) => `/shift ${num(value)} ${voice}`,
|
|
1802
|
+
reset: `/shift off ${voice}`,
|
|
1803
|
+
help: SAMPLE_CONTROLS.shift,
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
kind: "number",
|
|
1807
|
+
label: "formant",
|
|
1808
|
+
value: ref.formant,
|
|
1809
|
+
start: 0,
|
|
1810
|
+
off: "follow",
|
|
1811
|
+
min: -SCORE_LIMITS.maxSampleShift,
|
|
1812
|
+
max: SCORE_LIMITS.maxSampleShift,
|
|
1813
|
+
step: linear(
|
|
1814
|
+
1,
|
|
1815
|
+
-SCORE_LIMITS.maxSampleShift,
|
|
1816
|
+
SCORE_LIMITS.maxSampleShift,
|
|
1817
|
+
),
|
|
1818
|
+
format: (value) => (value === 0 ? "keep" : num(value)),
|
|
1819
|
+
command: (value) => set(`formant ${num(value)}`),
|
|
1820
|
+
reset: set("formant off"),
|
|
1821
|
+
help: `${SAMPLE_CONTROLS.formant} · needs shift`,
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
kind: "number",
|
|
1825
|
+
label: "fade in",
|
|
1826
|
+
value: ref.fadeInTime,
|
|
1827
|
+
start: 0.01,
|
|
1828
|
+
off: "off",
|
|
1829
|
+
min: 0,
|
|
1830
|
+
max: SCORE_LIMITS.maxSampleFadeSeconds,
|
|
1831
|
+
step: linear(0.01, 0, SCORE_LIMITS.maxSampleFadeSeconds),
|
|
1832
|
+
format: num,
|
|
1833
|
+
command: (value) => `/fade in ${num(value)} ${voice}`,
|
|
1834
|
+
reset: `/fade in off ${voice}`,
|
|
1835
|
+
help: SAMPLE_CONTROLS.fadeInTime,
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
kind: "number",
|
|
1839
|
+
label: "fade out",
|
|
1840
|
+
value: ref.fadeTime,
|
|
1841
|
+
start: 0.1,
|
|
1842
|
+
off: "off",
|
|
1843
|
+
min: 0,
|
|
1844
|
+
max: SCORE_LIMITS.maxSampleFadeSeconds,
|
|
1845
|
+
step: linear(0.05, 0, SCORE_LIMITS.maxSampleFadeSeconds),
|
|
1846
|
+
format: num,
|
|
1847
|
+
command: (value) => `/fade out ${num(value)} ${voice}`,
|
|
1848
|
+
reset: `/fade out off ${voice}`,
|
|
1849
|
+
help: SAMPLE_CONTROLS.fadeTime,
|
|
1850
|
+
},
|
|
1851
|
+
// 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
|
|
1852
|
+
{
|
|
1853
|
+
kind: "choice",
|
|
1854
|
+
label: "velocity layer",
|
|
1855
|
+
value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
|
|
1856
|
+
options: velocityLayerOptions(ref.vel),
|
|
1857
|
+
command: (option) => set(`vel ${option}`),
|
|
1858
|
+
help: `${SAMPLE_CONTROLS.vel} · two layers: 0-63 and 64-127 · three: 0-42 43-84 85-127`,
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
kind: "entry",
|
|
1862
|
+
label: "round robin",
|
|
1863
|
+
value: ref.rr ?? "",
|
|
1864
|
+
placeholder: "group name, e.g. sn",
|
|
1865
|
+
command: (text) => set(`rr ${text.trim() || "off"}`),
|
|
1866
|
+
example: `/sample set ${voice} rr sn`,
|
|
1867
|
+
help: SAMPLE_CONTROLS.rr,
|
|
1868
|
+
},
|
|
1236
1869
|
);
|
|
1237
1870
|
return nodes;
|
|
1238
1871
|
}
|
|
1239
1872
|
|
|
1873
|
+
/** Common velocity splits, plus the voice's own range when it is another. */
|
|
1874
|
+
function velocityLayerOptions(
|
|
1875
|
+
vel: readonly [number, number] | undefined,
|
|
1876
|
+
): string[] {
|
|
1877
|
+
const options = ["off", "0-63", "64-127", "0-42", "43-84", "85-127"];
|
|
1878
|
+
const own = vel ? `${vel[0]}-${vel[1]}` : undefined;
|
|
1879
|
+
return own && !options.includes(own) ? [...options, own] : options;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1240
1882
|
/** Synth preset first, then one row per parameter. */
|
|
1241
1883
|
/** The preset this track's voice still matches exactly, if any. */
|
|
1242
1884
|
function matchingSynthPreset(track: Track): string | undefined {
|
|
@@ -1249,17 +1891,25 @@ function matchingSynthPreset(track: Track): string | undefined {
|
|
|
1249
1891
|
)?.[0];
|
|
1250
1892
|
}
|
|
1251
1893
|
|
|
1252
|
-
/**
|
|
1253
|
-
function keysNodes(
|
|
1894
|
+
/** Modeled piano rows: the preset, then each parameter (`keys <p> <v>`). */
|
|
1895
|
+
function keysNodes(
|
|
1896
|
+
track: Track,
|
|
1897
|
+
params: readonly string[],
|
|
1898
|
+
presets: readonly string[] = keysPresetNames(track.instrument),
|
|
1899
|
+
): MenuNode[] {
|
|
1254
1900
|
const effective = resolvedKeys(track.instrument, track.keys);
|
|
1255
1901
|
const nodes: MenuNode[] = [
|
|
1256
1902
|
{
|
|
1257
1903
|
kind: "choice",
|
|
1258
1904
|
label: "preset",
|
|
1259
1905
|
value: track.keys?.preset ?? "—",
|
|
1260
|
-
options:
|
|
1906
|
+
options: presets,
|
|
1261
1907
|
command: (preset) => `keys preset ${preset}`,
|
|
1262
|
-
help:
|
|
1908
|
+
help: isOrganFamily(track.instrument)
|
|
1909
|
+
? "a starting organ; every value stays editable"
|
|
1910
|
+
: isElectricFamily(track.instrument)
|
|
1911
|
+
? "a starting electric piano or clav; every value stays editable"
|
|
1912
|
+
: "a starting piano; every value stays editable",
|
|
1263
1913
|
},
|
|
1264
1914
|
];
|
|
1265
1915
|
for (const key of params) {
|
|
@@ -1300,6 +1950,174 @@ function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
|
|
|
1300
1950
|
return nodes;
|
|
1301
1951
|
}
|
|
1302
1952
|
|
|
1953
|
+
// ---- f061-organ menu rows -------------------------------------------------
|
|
1954
|
+
|
|
1955
|
+
const PIANO_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
|
|
1956
|
+
isPianoFamily(KEYS_PRESETS[name]!.instrument),
|
|
1957
|
+
);
|
|
1958
|
+
const ORGAN_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
|
|
1959
|
+
isOrganFamily(KEYS_PRESETS[name]!.instrument),
|
|
1960
|
+
);
|
|
1961
|
+
const ELECTRIC_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
|
|
1962
|
+
isElectricFamily(KEYS_PRESETS[name]!.instrument),
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
/** The presets of the track's kind of keys (pianos, electric or organs). */
|
|
1966
|
+
function keysPresetNames(instrument: string): readonly string[] {
|
|
1967
|
+
if (isOrganFamily(instrument)) return ORGAN_PRESET_NAMES;
|
|
1968
|
+
if (isElectricFamily(instrument)) return ELECTRIC_PRESET_NAMES;
|
|
1969
|
+
return PIANO_PRESET_NAMES;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const DRAWBAR_FEET = [
|
|
1973
|
+
"16'",
|
|
1974
|
+
"5⅓'",
|
|
1975
|
+
"8'",
|
|
1976
|
+
"4'",
|
|
1977
|
+
"2⅔'",
|
|
1978
|
+
"2'",
|
|
1979
|
+
"1⅗'",
|
|
1980
|
+
"1⅓'",
|
|
1981
|
+
"1'",
|
|
1982
|
+
];
|
|
1983
|
+
const REGISTER_FEET = ["16'", "8'", "4'", "2⅔'", "2'"];
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* One 0-8 row per footage of a digit string (`keys drawbars 888000000`):
|
|
1987
|
+
* left/right pull a bar in or out, x puts the whole set back.
|
|
1988
|
+
*/
|
|
1989
|
+
function digitNodes(
|
|
1990
|
+
name: "drawbars" | "registers",
|
|
1991
|
+
text: string,
|
|
1992
|
+
feet: readonly string[],
|
|
1993
|
+
stored: boolean,
|
|
1994
|
+
): MenuNode[] {
|
|
1995
|
+
const digits = text.split("").map(Number);
|
|
1996
|
+
const nodes: MenuNode[] = feet.map((foot, index): MenuNode => {
|
|
1997
|
+
const at = (value: number) =>
|
|
1998
|
+
digits.map((d, i) => (i === index ? value : d)).join("");
|
|
1999
|
+
return {
|
|
2000
|
+
kind: "number",
|
|
2001
|
+
label: foot,
|
|
2002
|
+
value: digits[index]!,
|
|
2003
|
+
min: 0,
|
|
2004
|
+
max: 8,
|
|
2005
|
+
step: (value, direction) => Math.max(0, Math.min(8, value + direction)),
|
|
2006
|
+
format: (value) =>
|
|
2007
|
+
`${value} ${"█".repeat(value)}${"·".repeat(8 - value)}`,
|
|
2008
|
+
command: (value) => `keys ${name} ${at(value)}`,
|
|
2009
|
+
// x resets this bar only, to its default digit.
|
|
2010
|
+
reset: `keys ${name} ${at(Number(ORGAN_TEXT[name].default[index] ?? 0))}`,
|
|
2011
|
+
help: `${foot} ${name === "drawbars" ? "drawbar" : "register"} 0-8`,
|
|
2012
|
+
};
|
|
2013
|
+
});
|
|
2014
|
+
nodes.push({
|
|
2015
|
+
kind: "entry",
|
|
2016
|
+
label: `${name} (type)`,
|
|
2017
|
+
value: stored ? text : `${text} (default)`,
|
|
2018
|
+
placeholder: `${feet.length} digits 0-8`,
|
|
2019
|
+
command: (typed) =>
|
|
2020
|
+
new RegExp(`^[0-8]{${feet.length}}$`).test(typed.trim())
|
|
2021
|
+
? `keys ${name} ${typed.trim()}`
|
|
2022
|
+
: undefined,
|
|
2023
|
+
example: `keys ${name} ${text}`,
|
|
2024
|
+
});
|
|
2025
|
+
return nodes;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/** Pipe stops: a registration, then one toggle per stop. */
|
|
2029
|
+
function stopNodes(text: string): MenuNode[] {
|
|
2030
|
+
const on = pipeStops(text);
|
|
2031
|
+
const nodes: MenuNode[] = [
|
|
2032
|
+
{
|
|
2033
|
+
kind: "choice",
|
|
2034
|
+
label: "registration",
|
|
2035
|
+
value: Object.keys(PIPE_REGISTRATIONS).includes(text) ? text : "—",
|
|
2036
|
+
options: Object.keys(PIPE_REGISTRATIONS),
|
|
2037
|
+
command: (option) => `keys stops ${option}`,
|
|
2038
|
+
help: "a named registration (stops drawn together)",
|
|
2039
|
+
},
|
|
2040
|
+
];
|
|
2041
|
+
for (const stop of PIPE_STOPS)
|
|
2042
|
+
nodes.push({
|
|
2043
|
+
kind: "toggle",
|
|
2044
|
+
label: stop,
|
|
2045
|
+
value: on.includes(stop),
|
|
2046
|
+
command: (value) => {
|
|
2047
|
+
const next = value
|
|
2048
|
+
? PIPE_STOPS.filter((name) => name === stop || on.includes(name))
|
|
2049
|
+
: on.filter((name) => name !== stop);
|
|
2050
|
+
return next.length > 0
|
|
2051
|
+
? `keys stops ${next.join(" ")}`
|
|
2052
|
+
: "keys stops off";
|
|
2053
|
+
},
|
|
2054
|
+
help: `draw or retire the ${stop} stop`,
|
|
2055
|
+
});
|
|
2056
|
+
return nodes;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** The organ's rows: preset, its family's sub-menu, then its parameters. */
|
|
2060
|
+
function organNodes(track: Track): MenuNode[] {
|
|
2061
|
+
const family = track.instrument as OrganFamily;
|
|
2062
|
+
const effective = resolvedKeys(track.instrument, track.keys);
|
|
2063
|
+
const rows = ORGAN_ROWS[family].filter((row) => row in KEYS_PARAMS);
|
|
2064
|
+
const nodes = keysNodes(track, rows, ORGAN_PRESET_NAMES);
|
|
2065
|
+
const text = (name: keyof typeof ORGAN_TEXT) =>
|
|
2066
|
+
String(effective[name] ?? ORGAN_TEXT[name].default);
|
|
2067
|
+
const sub: MenuNode =
|
|
2068
|
+
family === "tonewheel"
|
|
2069
|
+
? {
|
|
2070
|
+
kind: "menu",
|
|
2071
|
+
id: "keys:drawbars",
|
|
2072
|
+
label: "drawbars",
|
|
2073
|
+
detail: text("drawbars"),
|
|
2074
|
+
help: "nine drawbars 16' to 1' (0 in, 8 full out)",
|
|
2075
|
+
build: (inner) => {
|
|
2076
|
+
const current = focused(inner) ?? track;
|
|
2077
|
+
const value = resolvedKeys(current.instrument, current.keys);
|
|
2078
|
+
return digitNodes(
|
|
2079
|
+
"drawbars",
|
|
2080
|
+
String(value.drawbars ?? ORGAN_TEXT.drawbars.default),
|
|
2081
|
+
DRAWBAR_FEET,
|
|
2082
|
+
current.keys?.drawbars !== undefined,
|
|
2083
|
+
);
|
|
2084
|
+
},
|
|
2085
|
+
}
|
|
2086
|
+
: family === "combo"
|
|
2087
|
+
? {
|
|
2088
|
+
kind: "menu",
|
|
2089
|
+
id: "keys:registers",
|
|
2090
|
+
label: "registers",
|
|
2091
|
+
detail: text("registers"),
|
|
2092
|
+
help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
|
|
2093
|
+
build: (inner) => {
|
|
2094
|
+
const current = focused(inner) ?? track;
|
|
2095
|
+
const value = resolvedKeys(current.instrument, current.keys);
|
|
2096
|
+
return digitNodes(
|
|
2097
|
+
"registers",
|
|
2098
|
+
String(value.registers ?? ORGAN_TEXT.registers.default),
|
|
2099
|
+
REGISTER_FEET,
|
|
2100
|
+
current.keys?.registers !== undefined,
|
|
2101
|
+
);
|
|
2102
|
+
},
|
|
2103
|
+
}
|
|
2104
|
+
: {
|
|
2105
|
+
kind: "menu",
|
|
2106
|
+
id: "keys:stops",
|
|
2107
|
+
label: "stops",
|
|
2108
|
+
detail: text("stops"),
|
|
2109
|
+
help: "pipe stops and registrations",
|
|
2110
|
+
build: (inner) => {
|
|
2111
|
+
const current = focused(inner) ?? track;
|
|
2112
|
+
const value = resolvedKeys(current.instrument, current.keys);
|
|
2113
|
+
return stopNodes(String(value.stops ?? ORGAN_TEXT.stops.default));
|
|
2114
|
+
},
|
|
2115
|
+
};
|
|
2116
|
+
// After the preset row, before the parameters.
|
|
2117
|
+
nodes.splice(1, 0, sub);
|
|
2118
|
+
return nodes;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
1303
2121
|
function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
|
|
1304
2122
|
const nodes: MenuNode[] = [];
|
|
1305
2123
|
if (keys === SYNTH_SIMPLE)
|
|
@@ -1363,9 +2181,9 @@ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
|
|
|
1363
2181
|
|
|
1364
2182
|
/** Plain names for the simple synth rows; the note shows the command name. */
|
|
1365
2183
|
const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
1366
|
-
lpf: "filter
|
|
1367
|
-
lpq: "filter res",
|
|
1368
|
-
lpenv: "filter env",
|
|
2184
|
+
lpf: "synth filter",
|
|
2185
|
+
lpq: "synth filter res",
|
|
2186
|
+
lpenv: "synth filter env",
|
|
1369
2187
|
vib: "vibrato",
|
|
1370
2188
|
fm: "FM amount",
|
|
1371
2189
|
};
|
|
@@ -1549,7 +2367,7 @@ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
|
|
|
1549
2367
|
nodes.push({
|
|
1550
2368
|
kind: "info",
|
|
1551
2369
|
label: "position automation",
|
|
1552
|
-
value: `${track.wtAutomation!.length} points · Automation ›
|
|
2370
|
+
value: `${track.wtAutomation!.length} points · Automation › table position`,
|
|
1553
2371
|
});
|
|
1554
2372
|
return nodes;
|
|
1555
2373
|
}
|
|
@@ -1564,23 +2382,44 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
1564
2382
|
return {
|
|
1565
2383
|
kind: "menu",
|
|
1566
2384
|
id: effect,
|
|
1567
|
-
label: spec.label
|
|
2385
|
+
label: spec.label,
|
|
1568
2386
|
detail: values ? effectSummary(effect, values) : "off",
|
|
1569
2387
|
help: spec.doc,
|
|
1570
2388
|
build: (inner) => effectParamNodes(inner, effect, false),
|
|
1571
2389
|
};
|
|
1572
2390
|
};
|
|
1573
2391
|
const rigStages = RIG_STAGES as readonly string[];
|
|
2392
|
+
// 0.6.1 shoegaze stages get their own sub-menu.
|
|
2393
|
+
const gaze = SHOEGAZE_EFFECTS as readonly string[];
|
|
1574
2394
|
const more = EFFECT_NAMES.filter(
|
|
1575
|
-
(effect) =>
|
|
2395
|
+
(effect) =>
|
|
2396
|
+
!CORE_EFFECTS.includes(effect) &&
|
|
2397
|
+
!rigStages.includes(effect) &&
|
|
2398
|
+
!gaze.includes(effect) &&
|
|
2399
|
+
// 0.7: the formant shift lives in Effects > Voice.
|
|
2400
|
+
effect !== "formant",
|
|
1576
2401
|
);
|
|
2402
|
+
const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
|
|
2403
|
+
effectValues(track, effect),
|
|
2404
|
+
);
|
|
2405
|
+
const shoegaze: MenuNode = {
|
|
2406
|
+
kind: "menu",
|
|
2407
|
+
id: "shoegaze",
|
|
2408
|
+
label: "shoegaze",
|
|
2409
|
+
help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
|
|
2410
|
+
detail:
|
|
2411
|
+
gazeOn.length === 0
|
|
2412
|
+
? "off"
|
|
2413
|
+
: gazeOn.map((effect) => effectSpec(effect).label).join(", "),
|
|
2414
|
+
build: () => SHOEGAZE_EFFECTS.map(node),
|
|
2415
|
+
};
|
|
1577
2416
|
const moreOn = more.filter((effect) => effectValues(track, effect));
|
|
1578
2417
|
// 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
|
|
1579
2418
|
const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
|
|
1580
2419
|
const rig: MenuNode = {
|
|
1581
2420
|
kind: "menu",
|
|
1582
2421
|
id: "guitar rig",
|
|
1583
|
-
label: "
|
|
2422
|
+
label: "guitar rig",
|
|
1584
2423
|
help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
|
|
1585
2424
|
detail:
|
|
1586
2425
|
rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
|
|
@@ -1602,6 +2441,20 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
1602
2441
|
return [
|
|
1603
2442
|
...CORE_EFFECTS.map(node),
|
|
1604
2443
|
rig,
|
|
2444
|
+
shoegaze,
|
|
2445
|
+
// Formant and vocoder live under Voice; this row is the way there.
|
|
2446
|
+
...(voiceEffectRows(context).length > 0
|
|
2447
|
+
? [
|
|
2448
|
+
{
|
|
2449
|
+
kind: "menu" as const,
|
|
2450
|
+
id: "voice",
|
|
2451
|
+
label: "voice effects",
|
|
2452
|
+
detail: "formant · vocoder · also in Voice",
|
|
2453
|
+
help: "formant and vocoder for voices (the same rows as Ctrl-K › Voice)",
|
|
2454
|
+
build: voiceEffectRows,
|
|
2455
|
+
},
|
|
2456
|
+
]
|
|
2457
|
+
: []),
|
|
1605
2458
|
{
|
|
1606
2459
|
kind: "menu",
|
|
1607
2460
|
id: "more effects",
|
|
@@ -1638,16 +2491,17 @@ function withUnit(text: string, unit: string | undefined): string {
|
|
|
1638
2491
|
|
|
1639
2492
|
function formatParam(spec: ParamSpec, value: number): string {
|
|
1640
2493
|
if (spec.kind !== "number") return num(value);
|
|
1641
|
-
|
|
1642
|
-
? `${Math.round(value)}`
|
|
1643
|
-
|
|
2494
|
+
const text =
|
|
2495
|
+
spec.step === "log" && value >= 100 ? `${Math.round(value)}` : num(value);
|
|
2496
|
+
// Rounding never leaves the range: attack 0.0001 types as 0.0001, not 0.
|
|
2497
|
+
return Number(text) < spec.min ? String(spec.min) : text;
|
|
1644
2498
|
}
|
|
1645
2499
|
|
|
1646
2500
|
/**
|
|
1647
2501
|
* One effect's rows: on/off, presets, then its simple parameters; the
|
|
1648
2502
|
* `advanced` submenu holds every parameter with its Strudel names.
|
|
1649
2503
|
*/
|
|
1650
|
-
function effectParamNodes(
|
|
2504
|
+
export function effectParamNodes(
|
|
1651
2505
|
context: MenuContext,
|
|
1652
2506
|
effect: EffectName,
|
|
1653
2507
|
advanced: boolean,
|
|
@@ -1738,6 +2592,37 @@ function effectParamNodes(
|
|
|
1738
2592
|
});
|
|
1739
2593
|
}
|
|
1740
2594
|
}
|
|
2595
|
+
if (!advanced && effect === "vowel") {
|
|
2596
|
+
// 0.7 vowel morph: To picks the target (morph starts halfway), Morph
|
|
2597
|
+
// moves between the two; both through `/vowel`.
|
|
2598
|
+
const to = values?.to;
|
|
2599
|
+
nodes.push(
|
|
2600
|
+
{
|
|
2601
|
+
kind: "choice",
|
|
2602
|
+
label: "to",
|
|
2603
|
+
value: typeof to === "string" ? to : "—",
|
|
2604
|
+
options: ["—", ...VOWEL_VALUES],
|
|
2605
|
+
command: (option) =>
|
|
2606
|
+
option === "—" ? "/vowel to off" : `/vowel to ${option}`,
|
|
2607
|
+
help: "a second vowel to morph toward (— keeps one vowel)",
|
|
2608
|
+
},
|
|
2609
|
+
{
|
|
2610
|
+
kind: "number",
|
|
2611
|
+
label: "morph",
|
|
2612
|
+
value: typeof values?.morph === "number" ? values.morph : undefined,
|
|
2613
|
+
start: 0.5,
|
|
2614
|
+
off: typeof to === "string" ? "0" : "set to first",
|
|
2615
|
+
min: 0,
|
|
2616
|
+
max: 1,
|
|
2617
|
+
step: specStep(spec.params.morph as NumberParam),
|
|
2618
|
+
format: (value) => num(value),
|
|
2619
|
+
command: (value) =>
|
|
2620
|
+
typeof to === "string" ? `/vowel morph ${num(value)}` : `/vowel to o`,
|
|
2621
|
+
help: "0 is the vowel, 1 is the to vowel; log-frequency formant glide, automatable as vowel-morph",
|
|
2622
|
+
...(typeof to === "string" ? { reset: "/vowel morph 0" } : {}),
|
|
2623
|
+
},
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
1741
2626
|
if (!advanced) {
|
|
1742
2627
|
nodes.push({
|
|
1743
2628
|
kind: "menu",
|
|
@@ -1770,8 +2655,18 @@ function automationNodes(context: MenuContext): MenuNode[] {
|
|
|
1770
2655
|
if (info?.effect === "synth")
|
|
1771
2656
|
return track.synth?.[info.param] !== undefined;
|
|
1772
2657
|
if (info?.effect === "string") return track.string !== undefined;
|
|
1773
|
-
|
|
2658
|
+
// Only the lanes this family's engine reads (keys-rotary on organs,
|
|
2659
|
+
// keys-hardness on pianos); the rest wait under "all lanes".
|
|
2660
|
+
if (info?.effect === "keys")
|
|
2661
|
+
return (
|
|
2662
|
+
track.keys !== undefined &&
|
|
2663
|
+
keysParamsFor(track.instrument).includes(info.param)
|
|
2664
|
+
);
|
|
1774
2665
|
if (info?.effect === "modal") return track.modal !== undefined;
|
|
2666
|
+
if (info?.effect === "grain") return track.granular !== undefined;
|
|
2667
|
+
if (info?.effect === "wind") return track.wind !== undefined;
|
|
2668
|
+
if (info?.effect === "sing") return track.sing !== undefined;
|
|
2669
|
+
if (info?.effect === "vocoder") return track.vocoder !== undefined;
|
|
1775
2670
|
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
1776
2671
|
});
|
|
1777
2672
|
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
@@ -1897,14 +2792,14 @@ function soundsDetail(track: Track | undefined): string {
|
|
|
1897
2792
|
}
|
|
1898
2793
|
|
|
1899
2794
|
/**
|
|
1900
|
-
* The instrument browser:
|
|
2795
|
+
* The instrument browser: kits and soundfont instruments from the
|
|
1901
2796
|
* built-in packs (fetched on first use), and the pack list.
|
|
1902
2797
|
*/
|
|
1903
2798
|
function kitsMenu(): MenuNode {
|
|
1904
2799
|
return {
|
|
1905
2800
|
kind: "menu",
|
|
1906
2801
|
id: "kits",
|
|
1907
|
-
label: "
|
|
2802
|
+
label: "kits",
|
|
1908
2803
|
help: "make this a drum track with a synth or sample kit",
|
|
1909
2804
|
detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
|
|
1910
2805
|
// Synth kits first (offline), then the pack sample kits.
|
|
@@ -1941,62 +2836,119 @@ function patternsMenu(): MenuNode {
|
|
|
1941
2836
|
return {
|
|
1942
2837
|
kind: "menu",
|
|
1943
2838
|
id: "patterns",
|
|
1944
|
-
label: "
|
|
1945
|
-
detail: `${DRUM_PATTERNS.length}
|
|
2839
|
+
label: "grooves",
|
|
2840
|
+
detail: `${DRUM_PATTERNS.length} ready-made drum parts`,
|
|
1946
2841
|
help: "replace the drum part with a ready-made groove",
|
|
1947
2842
|
build: () =>
|
|
1948
2843
|
DRUM_PATTERNS.map((entry): MenuNode => ({
|
|
1949
2844
|
kind: "action",
|
|
1950
2845
|
label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
1951
|
-
command: `/
|
|
2846
|
+
command: `/groove ${entry.name}`,
|
|
1952
2847
|
})),
|
|
1953
2848
|
};
|
|
1954
2849
|
}
|
|
1955
2850
|
|
|
1956
2851
|
const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
|
|
1957
2852
|
|
|
1958
|
-
function soundNodes(): MenuNode[] {
|
|
2853
|
+
function soundNodes(context: MenuContext): MenuNode[] {
|
|
1959
2854
|
return [
|
|
1960
2855
|
{
|
|
1961
2856
|
kind: "menu",
|
|
1962
2857
|
id: "group:keys",
|
|
1963
|
-
label: "
|
|
1964
|
-
help: "
|
|
1965
|
-
detail:
|
|
1966
|
-
build: () =>
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2858
|
+
label: "keys",
|
|
2859
|
+
help: "modeled pianos, electric keys and organs, built in (no download)",
|
|
2860
|
+
detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
|
|
2861
|
+
build: () => [
|
|
2862
|
+
...PIANO_PRESET_NAMES.map((name): MenuNode => {
|
|
2863
|
+
const preset = KEYS_PRESETS[name]!;
|
|
2864
|
+
return {
|
|
2865
|
+
kind: "action",
|
|
2866
|
+
label: `${name.padEnd(10)} ${preset.doc}`,
|
|
2867
|
+
command: `piano ${name}`,
|
|
2868
|
+
help: preset.styles,
|
|
2869
|
+
};
|
|
2870
|
+
}),
|
|
2871
|
+
// keys-electric (0.6.1): tine and reed pianos and the clavinet.
|
|
2872
|
+
{
|
|
2873
|
+
kind: "menu",
|
|
2874
|
+
id: "group:keys:electric",
|
|
2875
|
+
label: "electric",
|
|
2876
|
+
help: "electric pianos (tine, reed) and the clavinet, modeled",
|
|
2877
|
+
detail: ELECTRIC_PRESET_NAMES.join(" "),
|
|
2878
|
+
build: () =>
|
|
2879
|
+
Object.entries(KEYS_PRESETS)
|
|
2880
|
+
.filter(([, preset]) => isElectricFamily(preset.instrument))
|
|
2881
|
+
.map(([name, preset]): MenuNode => ({
|
|
2882
|
+
kind: "action",
|
|
2883
|
+
label: `${name.padEnd(10)} ${preset.doc}`,
|
|
2884
|
+
command: `keys preset ${name}`,
|
|
2885
|
+
help: preset.styles,
|
|
2886
|
+
})),
|
|
2887
|
+
},
|
|
2888
|
+
// f061-organ
|
|
2889
|
+
{
|
|
2890
|
+
kind: "menu",
|
|
2891
|
+
id: "group:organs",
|
|
2892
|
+
label: "organs",
|
|
2893
|
+
help: "tonewheel, combo and pipe organs on the keys engine",
|
|
2894
|
+
detail: ORGAN_PRESET_NAMES.join(" "),
|
|
2895
|
+
build: () =>
|
|
2896
|
+
ORGAN_PRESET_NAMES.map((name): MenuNode => {
|
|
2897
|
+
const preset = KEYS_PRESETS[name]!;
|
|
2898
|
+
return {
|
|
2899
|
+
kind: "action",
|
|
2900
|
+
label: `${name.padEnd(10)} ${preset.doc}`,
|
|
2901
|
+
command: name,
|
|
2902
|
+
help: preset.styles,
|
|
2903
|
+
};
|
|
2904
|
+
}),
|
|
2905
|
+
},
|
|
2906
|
+
],
|
|
1973
2907
|
},
|
|
1974
2908
|
{
|
|
1975
2909
|
kind: "action",
|
|
1976
|
-
label: "wavetable
|
|
2910
|
+
label: "wavetable basic shapes morph · built-in",
|
|
1977
2911
|
command: "wt basic",
|
|
1978
2912
|
help: "switch this track to the morphing wavetable synth",
|
|
1979
2913
|
},
|
|
1980
2914
|
// 0.6 instrument groups, one per lane.
|
|
1981
2915
|
malletsMenu(),
|
|
2916
|
+
windsMenu(),
|
|
1982
2917
|
{
|
|
1983
2918
|
kind: "menu",
|
|
1984
2919
|
id: "strings",
|
|
1985
|
-
label: "
|
|
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.
|
|
2920
|
+
label: "strings",
|
|
2921
|
+
help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
|
|
2922
|
+
detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
|
|
2923
|
+
build: () => [
|
|
2924
|
+
...STRING_PRESET_NAMES.filter(
|
|
2925
|
+
(name) => !BOWED_PRESET_NAMES.includes(name),
|
|
2926
|
+
).map((name): MenuNode => ({
|
|
1990
2927
|
kind: "action",
|
|
1991
2928
|
label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
|
|
1992
2929
|
command: `string ${name}`,
|
|
1993
2930
|
help: STRING_PRESETS[name]!.styles,
|
|
1994
2931
|
})),
|
|
2932
|
+
{
|
|
2933
|
+
kind: "menu",
|
|
2934
|
+
id: "strings:bowed",
|
|
2935
|
+
label: "bowed",
|
|
2936
|
+
help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
|
|
2937
|
+
detail: BOWED_PRESET_NAMES.join(" "),
|
|
2938
|
+
build: () =>
|
|
2939
|
+
BOWED_PRESET_NAMES.map((name): MenuNode => ({
|
|
2940
|
+
kind: "action",
|
|
2941
|
+
label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
|
|
2942
|
+
command: `bowed ${name}`,
|
|
2943
|
+
help: STRING_PRESETS[name]!.styles,
|
|
2944
|
+
})),
|
|
2945
|
+
},
|
|
2946
|
+
],
|
|
1995
2947
|
},
|
|
1996
2948
|
{
|
|
1997
2949
|
kind: "menu",
|
|
1998
2950
|
id: "granular-browse",
|
|
1999
|
-
label: "
|
|
2951
|
+
label: "granular",
|
|
2000
2952
|
help: "grain clouds from a built-in synth or this track's own sound",
|
|
2001
2953
|
detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
|
|
2002
2954
|
build: granularBrowseNodes,
|
|
@@ -2004,7 +2956,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2004
2956
|
{
|
|
2005
2957
|
kind: "menu",
|
|
2006
2958
|
id: "instruments",
|
|
2007
|
-
label: "instruments",
|
|
2959
|
+
label: "all instruments",
|
|
2008
2960
|
help: "sampled piano and General MIDI instruments",
|
|
2009
2961
|
detail: "General MIDI soundfont, piano",
|
|
2010
2962
|
build: () => [
|
|
@@ -2022,9 +2974,9 @@ function soundNodes(): MenuNode[] {
|
|
|
2022
2974
|
},
|
|
2023
2975
|
{
|
|
2024
2976
|
kind: "entry",
|
|
2025
|
-
label: "use a
|
|
2977
|
+
label: "use a sample",
|
|
2026
2978
|
value: "",
|
|
2027
|
-
help: "type a pack
|
|
2979
|
+
help: "type a pack sample name, e.g. dirt-samples/bd:3",
|
|
2028
2980
|
placeholder: "<pack>/<sound>[:<n>]",
|
|
2029
2981
|
command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
|
|
2030
2982
|
example: "/pack use dirt-samples/bd:3",
|
|
@@ -2069,6 +3021,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2069
3021
|
label: "tempo",
|
|
2070
3022
|
help: "beats per minute",
|
|
2071
3023
|
value: score.tempoBpm,
|
|
3024
|
+
start: START_TEMPO_BPM,
|
|
2072
3025
|
min: SCORE_LIMITS.minTempoBpm,
|
|
2073
3026
|
max: SCORE_LIMITS.maxTempoBpm,
|
|
2074
3027
|
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
@@ -2080,6 +3033,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2080
3033
|
label: "beats per bar",
|
|
2081
3034
|
help: "the meter",
|
|
2082
3035
|
value: score.beatsPerBar,
|
|
3036
|
+
start: START_BEATS_PER_BAR,
|
|
2083
3037
|
min: 1,
|
|
2084
3038
|
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
2085
3039
|
step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
|
|
@@ -2092,20 +3046,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2092
3046
|
label: "loop length",
|
|
2093
3047
|
help: "how long the loop is",
|
|
2094
3048
|
value: score.bars,
|
|
3049
|
+
start: START_BARS,
|
|
2095
3050
|
min: 1,
|
|
2096
3051
|
max: SCORE_LIMITS.maxBars,
|
|
2097
3052
|
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
2098
3053
|
format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
|
|
2099
3054
|
command: (value) => `bars ${Math.round(value)}`,
|
|
2100
3055
|
},
|
|
2101
|
-
|
|
2102
|
-
kind: "choice",
|
|
2103
|
-
label: "grid",
|
|
2104
|
-
help: "the step play-mode recording and the euclid editor snap to",
|
|
2105
|
-
value: context.grid,
|
|
2106
|
-
options: context.grids,
|
|
2107
|
-
command: (option) => `/grid ${option}`,
|
|
2108
|
-
},
|
|
3056
|
+
gridNode(context),
|
|
2109
3057
|
{
|
|
2110
3058
|
kind: "toggle",
|
|
2111
3059
|
label: "click",
|
|
@@ -2121,17 +3069,119 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2121
3069
|
options: ["0", "1", "2"],
|
|
2122
3070
|
command: (option) => `/count-in ${option}`,
|
|
2123
3071
|
},
|
|
3072
|
+
{
|
|
3073
|
+
kind: "choice",
|
|
3074
|
+
label: "calibration",
|
|
3075
|
+
help: "sound fixes: 1 chokes hats, tunes toms, levels keys, steadies brass · 0 keeps the legacy sound",
|
|
3076
|
+
value: String(score.calibration ?? 0),
|
|
3077
|
+
options: Array.from({ length: CALIBRATION_LATEST + 1 }, (_, i) =>
|
|
3078
|
+
String(i),
|
|
3079
|
+
),
|
|
3080
|
+
command: (option) => `/calibration ${option}`,
|
|
3081
|
+
},
|
|
2124
3082
|
{
|
|
2125
3083
|
kind: "menu",
|
|
2126
|
-
id: "
|
|
2127
|
-
label: "
|
|
2128
|
-
detail:
|
|
2129
|
-
help: "the
|
|
2130
|
-
build:
|
|
3084
|
+
id: "resample",
|
|
3085
|
+
label: "resample",
|
|
3086
|
+
detail: "track · section · bars → new sample track",
|
|
3087
|
+
help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
|
|
3088
|
+
build: resampleNodes,
|
|
3089
|
+
},
|
|
3090
|
+
{
|
|
3091
|
+
kind: "menu",
|
|
3092
|
+
id: "export",
|
|
3093
|
+
label: "export",
|
|
3094
|
+
detail: "project file · MIDI · WAV",
|
|
3095
|
+
help: "save the song as a project file or MIDI; audio renders offline",
|
|
3096
|
+
build: exportNodes,
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
kind: "menu",
|
|
3100
|
+
id: "session",
|
|
3101
|
+
label: "session",
|
|
3102
|
+
detail: context.sessionName ?? "rename · fork · resume",
|
|
3103
|
+
help: "rename, fork or resume a session",
|
|
3104
|
+
build: sessionNodes,
|
|
3105
|
+
},
|
|
3106
|
+
{
|
|
3107
|
+
kind: "menu",
|
|
3108
|
+
id: "agent",
|
|
3109
|
+
label: "agent",
|
|
3110
|
+
detail: context.showMe
|
|
3111
|
+
? `show me ${context.showMe}`
|
|
3112
|
+
: "model · model key",
|
|
3113
|
+
help: "the agent's model, show-me and model key",
|
|
3114
|
+
build: agentNodes,
|
|
3115
|
+
},
|
|
3116
|
+
{
|
|
3117
|
+
kind: "menu",
|
|
3118
|
+
id: "help",
|
|
3119
|
+
label: "help and guides",
|
|
3120
|
+
detail: "help · guides · keys",
|
|
3121
|
+
help: "the command reference, the guides and the keys",
|
|
3122
|
+
build: helpNodes,
|
|
2131
3123
|
},
|
|
2132
3124
|
];
|
|
2133
3125
|
}
|
|
2134
3126
|
|
|
3127
|
+
/** Project > Resample: one action per useful source and range. */
|
|
3128
|
+
function resampleNodes(context: MenuContext): MenuNode[] {
|
|
3129
|
+
const track = focused(context);
|
|
3130
|
+
const score = context.score;
|
|
3131
|
+
const nodes: MenuNode[] = [];
|
|
3132
|
+
if (track) {
|
|
3133
|
+
nodes.push(
|
|
3134
|
+
{
|
|
3135
|
+
kind: "action",
|
|
3136
|
+
label: `${track.id} → sampler`,
|
|
3137
|
+
command: `resample ${track.id}`,
|
|
3138
|
+
help: "render this track (pre-master) to a one-shot sampler track",
|
|
3139
|
+
},
|
|
3140
|
+
{
|
|
3141
|
+
kind: "action",
|
|
3142
|
+
label: `${track.id} → granular`,
|
|
3143
|
+
command: `resample ${track.id} grain`,
|
|
3144
|
+
help: "render this track to a granular track (cloud preset) holding one note",
|
|
3145
|
+
},
|
|
3146
|
+
);
|
|
3147
|
+
for (const section of score.sections)
|
|
3148
|
+
nodes.push({
|
|
3149
|
+
kind: "action",
|
|
3150
|
+
label: `${track.id} · section ${section.name}`,
|
|
3151
|
+
command: `resample ${track.id} section ${section.name}`,
|
|
3152
|
+
help: `render bars ${section.startBar + 1}-${section.startBar + section.bars} of this track`,
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
nodes.push(
|
|
3156
|
+
{
|
|
3157
|
+
kind: "entry",
|
|
3158
|
+
label: "bars",
|
|
3159
|
+
value: "",
|
|
3160
|
+
placeholder: "a-b [grain]",
|
|
3161
|
+
example: "/resample <track> bars 1-2",
|
|
3162
|
+
command: (text) => {
|
|
3163
|
+
const match = /^\s*(\d{1,4})(?:-(\d{1,4}))?(\s+grain)?\s*$/.exec(text);
|
|
3164
|
+
if (!match || !track) return undefined;
|
|
3165
|
+
return `resample ${track.id} bars ${match[1]}-${match[2] ?? match[1]}${match[3] ? " grain" : ""}`;
|
|
3166
|
+
},
|
|
3167
|
+
help: "render a bar range of the focused track",
|
|
3168
|
+
},
|
|
3169
|
+
{
|
|
3170
|
+
kind: "action",
|
|
3171
|
+
label: "mix → sampler",
|
|
3172
|
+
command: "resample orbit 1",
|
|
3173
|
+
help: "pre-master: render every track on orbit 1 (the default bus) without the song master",
|
|
3174
|
+
},
|
|
3175
|
+
{
|
|
3176
|
+
kind: "action",
|
|
3177
|
+
label: "master → sampler",
|
|
3178
|
+
command: "resample master",
|
|
3179
|
+
help: "render the whole song through the master chain",
|
|
3180
|
+
},
|
|
3181
|
+
);
|
|
3182
|
+
return nodes;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
2135
3185
|
// ── tuning ────────────────────────────────────────────────────────────
|
|
2136
3186
|
|
|
2137
3187
|
/** Short name of a tuning for a choice row: its library name or table. */
|
|
@@ -2297,7 +3347,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2297
3347
|
help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
|
|
2298
3348
|
value: scale,
|
|
2299
3349
|
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
2300
|
-
command: (option) => `
|
|
3350
|
+
command: (option) => `key ${option}`,
|
|
2301
3351
|
},
|
|
2302
3352
|
{
|
|
2303
3353
|
kind: "choice",
|
|
@@ -2305,7 +3355,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2305
3355
|
help: "the song key's tonic; keeps the scale",
|
|
2306
3356
|
value: tonic,
|
|
2307
3357
|
options: TONICS,
|
|
2308
|
-
command: (option) => `
|
|
3358
|
+
command: (option) => `key ${option} ${scale}`,
|
|
2309
3359
|
},
|
|
2310
3360
|
{
|
|
2311
3361
|
kind: "action",
|
|
@@ -2316,7 +3366,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2316
3366
|
{
|
|
2317
3367
|
kind: "action",
|
|
2318
3368
|
label: "list scales",
|
|
2319
|
-
command: "
|
|
3369
|
+
command: "key list",
|
|
2320
3370
|
help: "every mode and scale, by family",
|
|
2321
3371
|
},
|
|
2322
3372
|
];
|
|
@@ -2358,14 +3408,7 @@ type Frame = {
|
|
|
2358
3408
|
hovering?: boolean;
|
|
2359
3409
|
};
|
|
2360
3410
|
|
|
2361
|
-
const
|
|
2362
|
-
const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
|
|
2363
|
-
const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
2364
|
-
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
2365
|
-
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
2366
|
-
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
2367
|
-
const KEY_DELETE = new Set(["\u001b[3~", "x"]);
|
|
2368
|
-
const LABEL_WIDTH = 16;
|
|
3411
|
+
export const LABEL_WIDTH = 16;
|
|
2369
3412
|
|
|
2370
3413
|
/**
|
|
2371
3414
|
* A submenu frame whose rows are rebuilt from the parent's fresh build, so
|
|
@@ -2418,10 +3461,8 @@ export class EditMenu {
|
|
|
2418
3461
|
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
2419
3462
|
this.filtering = false;
|
|
2420
3463
|
this.entry = undefined;
|
|
2421
|
-
// Walk the section path (`automation` is Mix
|
|
2422
|
-
for (const id of section
|
|
2423
|
-
? (SECTION_ALIASES[section.toLowerCase()] ?? [])
|
|
2424
|
-
: []) {
|
|
3464
|
+
// Walk the section path (`automation` is Mix › automation).
|
|
3465
|
+
for (const id of section ? (menuSectionPath(context, section) ?? []) : []) {
|
|
2425
3466
|
const frame = this.stack.at(-1)!;
|
|
2426
3467
|
const nodes = frame.build(context);
|
|
2427
3468
|
const index = nodes.findIndex(
|
|
@@ -2528,9 +3569,15 @@ export class EditMenu {
|
|
|
2528
3569
|
if (!frame) return [];
|
|
2529
3570
|
const all = frame.build(context);
|
|
2530
3571
|
const needle = frame.query.toLowerCase();
|
|
2531
|
-
return
|
|
2532
|
-
|
|
2533
|
-
|
|
3572
|
+
if (!needle) return all;
|
|
3573
|
+
const direct = all.filter((node) =>
|
|
3574
|
+
nodeText(node).toLowerCase().includes(needle),
|
|
3575
|
+
);
|
|
3576
|
+
// At the root a filter also looks two levels down for groups
|
|
3577
|
+
// (`/autotune`, `/formant`, `/tuning`) and offers them by path, after
|
|
3578
|
+
// the roots that match.
|
|
3579
|
+
if (this.stack.length !== 1) return direct;
|
|
3580
|
+
return [...direct, ...deepGroups(all, context, needle)];
|
|
2534
3581
|
}
|
|
2535
3582
|
|
|
2536
3583
|
private selected(context: MenuContext): MenuNode | undefined {
|
|
@@ -2558,11 +3605,7 @@ export class EditMenu {
|
|
|
2558
3605
|
frame.index = 0;
|
|
2559
3606
|
return { type: "handled" };
|
|
2560
3607
|
}
|
|
2561
|
-
if (
|
|
2562
|
-
KEY_ENTER.has(value) ||
|
|
2563
|
-
value === "\u001b[B" ||
|
|
2564
|
-
value === "\u001b[A"
|
|
2565
|
-
) {
|
|
3608
|
+
if (KEY_ENTER.has(value) || value === ARROW_DOWN || value === ARROW_UP) {
|
|
2566
3609
|
this.filtering = false;
|
|
2567
3610
|
return this.key(value, context);
|
|
2568
3611
|
}
|
|
@@ -2601,11 +3644,11 @@ export class EditMenu {
|
|
|
2601
3644
|
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
2602
3645
|
return this.hovered(frame, context);
|
|
2603
3646
|
}
|
|
2604
|
-
if (value
|
|
3647
|
+
if (KEY_PAGE_UP.has(value) || KEY_HOME.has(value)) {
|
|
2605
3648
|
frame.index = 0;
|
|
2606
3649
|
return this.hovered(frame, context);
|
|
2607
3650
|
}
|
|
2608
|
-
if (value
|
|
3651
|
+
if (KEY_PAGE_DOWN.has(value) || KEY_END.has(value)) {
|
|
2609
3652
|
frame.index = Math.max(0, nodes.length - 1);
|
|
2610
3653
|
return this.hovered(frame, context);
|
|
2611
3654
|
}
|
|
@@ -2624,11 +3667,19 @@ export class EditMenu {
|
|
|
2624
3667
|
node.kind === "info")
|
|
2625
3668
|
)
|
|
2626
3669
|
return { type: "keep" };
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
3670
|
+
// ← → adjust a value; on any other row ← h go back and → l go in
|
|
3671
|
+
// (or run the action), and - + do nothing.
|
|
3672
|
+
const isValue = isValueNode(node);
|
|
3673
|
+
if (isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
|
|
3674
|
+
return this.nudge(node, KEY_RIGHT.has(value) ? 1 : -1);
|
|
3675
|
+
if (!isValue && KEY_BACK.has(value)) return this.back();
|
|
3676
|
+
if (!isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
|
|
3677
|
+
if (!(
|
|
3678
|
+
KEY_FORWARD.has(value) &&
|
|
3679
|
+
(node.kind === "menu" || node.kind === "action")
|
|
3680
|
+
))
|
|
3681
|
+
return { type: "handled" };
|
|
3682
|
+
if (KEY_RESET.has(value) && node.kind === "number") {
|
|
2632
3683
|
const reset =
|
|
2633
3684
|
node.reset ??
|
|
2634
3685
|
(node.start !== undefined && node.value !== undefined
|
|
@@ -2636,7 +3687,7 @@ export class EditMenu {
|
|
|
2636
3687
|
: undefined);
|
|
2637
3688
|
return reset ? { type: "run", command: reset } : { type: "handled" };
|
|
2638
3689
|
}
|
|
2639
|
-
if (
|
|
3690
|
+
if (KEY_RESET.has(value) && node.kind === "point")
|
|
2640
3691
|
return {
|
|
2641
3692
|
type: "run",
|
|
2642
3693
|
command: `automate ${node.lane} remove ${num(node.beat)}`,
|
|
@@ -2648,7 +3699,7 @@ export class EditMenu {
|
|
|
2648
3699
|
this.entry = { label: node.label, buffer: value };
|
|
2649
3700
|
return { type: "handled" };
|
|
2650
3701
|
}
|
|
2651
|
-
if (KEY_ENTER.has(value) || value === " ") {
|
|
3702
|
+
if (KEY_ENTER.has(value) || KEY_FORWARD.has(value) || value === " ") {
|
|
2652
3703
|
if (node.kind === "menu") {
|
|
2653
3704
|
// Going back lands on the opened row with the filter cleared.
|
|
2654
3705
|
if (frame.query) {
|
|
@@ -2724,15 +3775,16 @@ export class EditMenu {
|
|
|
2724
3775
|
return { type: "hover", command: node.command, key: frame.hover };
|
|
2725
3776
|
}
|
|
2726
3777
|
|
|
3778
|
+
/** ← on a row that is not a value: up one level (never past the root). */
|
|
3779
|
+
private back(): MenuResult {
|
|
3780
|
+
if (this.stack.length <= 1) return { type: "handled" };
|
|
3781
|
+
const left = this.stack.pop();
|
|
3782
|
+
if (left?.hover && left.hovering)
|
|
3783
|
+
return { type: "unhover", key: left.hover };
|
|
3784
|
+
return { type: "handled" };
|
|
3785
|
+
}
|
|
3786
|
+
|
|
2727
3787
|
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
2728
|
-
if (node.kind === "menu") {
|
|
2729
|
-
if (direction > 0) {
|
|
2730
|
-
this.stack.push(childFrame(this.stack.at(-1)!, node));
|
|
2731
|
-
return { type: "handled" };
|
|
2732
|
-
}
|
|
2733
|
-
if (this.stack.length > 1) this.stack.pop();
|
|
2734
|
-
return { type: "handled" };
|
|
2735
|
-
}
|
|
2736
3788
|
if (node.kind === "number") {
|
|
2737
3789
|
const next =
|
|
2738
3790
|
node.value === undefined
|
|
@@ -2765,11 +3817,6 @@ export class EditMenu {
|
|
|
2765
3817
|
? { type: "handled" }
|
|
2766
3818
|
: { type: "run", command: node.command(next) };
|
|
2767
3819
|
}
|
|
2768
|
-
if (direction < 0 && this.stack.length > 1) {
|
|
2769
|
-
const left = this.stack.pop();
|
|
2770
|
-
if (left?.hover && left.hovering)
|
|
2771
|
-
return { type: "unhover", key: left.hover };
|
|
2772
|
-
}
|
|
2773
3820
|
return { type: "handled" };
|
|
2774
3821
|
}
|
|
2775
3822
|
|
|
@@ -2900,6 +3947,16 @@ export class EditMenu {
|
|
|
2900
3947
|
}
|
|
2901
3948
|
}
|
|
2902
3949
|
|
|
3950
|
+
/** Rows that hold a value: ← → adjust them instead of navigating. */
|
|
3951
|
+
function isValueNode(node: MenuNode): boolean {
|
|
3952
|
+
return (
|
|
3953
|
+
node.kind === "number" ||
|
|
3954
|
+
node.kind === "point" ||
|
|
3955
|
+
node.kind === "toggle" ||
|
|
3956
|
+
node.kind === "choice"
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
|
|
2903
3960
|
/** The focused row's one-line description. */
|
|
2904
3961
|
function describe(node: MenuNode): string {
|
|
2905
3962
|
if (node.help) return node.help;
|
|
@@ -3061,28 +4118,155 @@ function commandText(node: MenuNode): string | undefined {
|
|
|
3061
4118
|
}
|
|
3062
4119
|
}
|
|
3063
4120
|
|
|
4121
|
+
/** Menu groups below `nodes` (two levels) whose label matches `needle`. */
|
|
4122
|
+
function deepGroups(
|
|
4123
|
+
nodes: readonly MenuNode[],
|
|
4124
|
+
context: MenuContext,
|
|
4125
|
+
needle: string,
|
|
4126
|
+
): MenuNode[] {
|
|
4127
|
+
const found: MenuNode[] = [];
|
|
4128
|
+
const visit = (list: readonly MenuNode[], path: string, depth: number) => {
|
|
4129
|
+
for (const node of list) {
|
|
4130
|
+
if (node.kind !== "menu" || found.length >= 12) continue;
|
|
4131
|
+
const label = path ? `${path} › ${node.label}` : node.label;
|
|
4132
|
+
if (path && node.label.toLowerCase().includes(needle))
|
|
4133
|
+
found.push({ ...node, label });
|
|
4134
|
+
if (depth < 2) {
|
|
4135
|
+
let children: MenuNode[] = [];
|
|
4136
|
+
try {
|
|
4137
|
+
children = node.build(context);
|
|
4138
|
+
} catch {
|
|
4139
|
+
continue;
|
|
4140
|
+
}
|
|
4141
|
+
visit(children, label, depth + 1);
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
};
|
|
4145
|
+
visit(nodes, "", 0);
|
|
4146
|
+
return found;
|
|
4147
|
+
}
|
|
4148
|
+
|
|
3064
4149
|
function nodeText(node: MenuNode): string {
|
|
3065
4150
|
return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
|
|
3066
4151
|
}
|
|
3067
4152
|
|
|
3068
|
-
/**
|
|
3069
|
-
export const MENU_SECTIONS =
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
4153
|
+
/** Every id `/menu <id>` opens: the ten topics first, then older ids. */
|
|
4154
|
+
export const MENU_SECTIONS: readonly string[] = Object.freeze(
|
|
4155
|
+
Object.keys(SECTION_ALIASES),
|
|
4156
|
+
);
|
|
4157
|
+
|
|
4158
|
+
/** The ten topic ids, the roots `/menu` names first. */
|
|
4159
|
+
export const MENU_TOPICS: readonly string[] = Object.freeze(
|
|
4160
|
+
MENU_SECTIONS.slice(0, 10),
|
|
4161
|
+
);
|
|
4162
|
+
|
|
4163
|
+
/** The names `/menu` and `/help menu` list: one per topic, no aliases. */
|
|
4164
|
+
export const MENU_SHOWN_SECTIONS: readonly string[] = MENU_TOPICS;
|
|
4165
|
+
|
|
4166
|
+
/** `/menu`'s usage line: the short form; `/help menu` lists every topic. */
|
|
4167
|
+
export const MENU_USAGE = "usage: /menu <topic or row> · /help menu lists them";
|
|
4168
|
+
|
|
4169
|
+
/**
|
|
4170
|
+
* The error for `/menu <section>` when nothing matches: the nearest topic
|
|
4171
|
+
* or row name first, so it fits 80 columns, then the short usage.
|
|
4172
|
+
*/
|
|
4173
|
+
export function menuUsage(context: MenuContext, section: string): string {
|
|
4174
|
+
const names = new Set<string>(MENU_SECTIONS);
|
|
4175
|
+
for (const node of rootNodes(context))
|
|
4176
|
+
if (node.kind === "menu") {
|
|
4177
|
+
let children: MenuNode[] = [];
|
|
4178
|
+
try {
|
|
4179
|
+
children = node.build(context);
|
|
4180
|
+
} catch {
|
|
4181
|
+
continue;
|
|
4182
|
+
}
|
|
4183
|
+
for (const child of children)
|
|
4184
|
+
if (child.kind === "menu") names.add(labelName(child.label));
|
|
4185
|
+
}
|
|
4186
|
+
const near = nearest(section, names);
|
|
4187
|
+
return `no menu "${section}"${near ? ` · did you mean /menu ${near}?` : ""} · /menu <topic or row>`;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
/** A row label without its padded detail: `granular 12 presets` → `granular`. */
|
|
4191
|
+
function labelName(label: string): string {
|
|
4192
|
+
return label
|
|
4193
|
+
.split(/\s{2,}/)[0]!
|
|
4194
|
+
.trim()
|
|
4195
|
+
.toLowerCase();
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
/**
|
|
4199
|
+
* The path of menu ids `/menu <section>` walks: a topic or older id from
|
|
4200
|
+
* SECTION_ALIASES, else the first menu row (three levels deep, breadth
|
|
4201
|
+
* first) whose id or label is `section`. Undefined when nothing matches.
|
|
4202
|
+
*/
|
|
4203
|
+
export function menuSectionPath(
|
|
4204
|
+
context: MenuContext,
|
|
4205
|
+
section: string,
|
|
4206
|
+
): readonly string[] | undefined {
|
|
4207
|
+
const needle = section.trim().toLowerCase();
|
|
4208
|
+
if (!needle) return undefined;
|
|
4209
|
+
const alias = SECTION_ALIASES[needle];
|
|
4210
|
+
if (alias) return alias;
|
|
4211
|
+
type Step = { nodes: readonly MenuNode[]; path: readonly string[] };
|
|
4212
|
+
let level: Step[] = [{ nodes: rootNodes(context), path: [] }];
|
|
4213
|
+
for (let depth = 0; depth < 3 && level.length; depth += 1) {
|
|
4214
|
+
const next: Step[] = [];
|
|
4215
|
+
for (const { nodes, path } of level) {
|
|
4216
|
+
for (const node of nodes) {
|
|
4217
|
+
if (node.kind !== "menu") continue;
|
|
4218
|
+
const at = [...path, node.id];
|
|
4219
|
+
if (node.id === needle || node.label.toLowerCase() === needle)
|
|
4220
|
+
return at;
|
|
4221
|
+
if (depth < 2) {
|
|
4222
|
+
try {
|
|
4223
|
+
next.push({ nodes: node.build(context), path: at });
|
|
4224
|
+
} catch {
|
|
4225
|
+
// A section that cannot build here has no rows to match.
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
level = next;
|
|
4231
|
+
}
|
|
4232
|
+
return undefined;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
/** A one-track project, for breadcrumbs asked for without a window. */
|
|
4236
|
+
function breadcrumbContext(): MenuContext {
|
|
4237
|
+
return {
|
|
4238
|
+
score: createScore({
|
|
4239
|
+
tempoBpm: 120,
|
|
4240
|
+
bars: 8,
|
|
4241
|
+
tracks: [{ id: "keys", name: "keys", instrument: "piano" }],
|
|
4242
|
+
notes: [],
|
|
4243
|
+
}),
|
|
4244
|
+
trackId: "keys",
|
|
4245
|
+
playing: false,
|
|
4246
|
+
grid: "1/16",
|
|
4247
|
+
grids: ["1/16"],
|
|
4248
|
+
clickOn: false,
|
|
4249
|
+
countInBars: 1,
|
|
4250
|
+
};
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
/**
|
|
4254
|
+
* `Ctrl-K › Chords and key › tuning`: where `/menu <id>` opens, from the
|
|
4255
|
+
* live labels. Undefined when `id` opens nothing.
|
|
4256
|
+
*/
|
|
4257
|
+
export function menuPath(
|
|
4258
|
+
id: string,
|
|
4259
|
+
context: MenuContext = breadcrumbContext(),
|
|
4260
|
+
): string | undefined {
|
|
4261
|
+
const path = menuSectionPath(context, id);
|
|
4262
|
+
if (!path) return undefined;
|
|
4263
|
+
const labels: string[] = [];
|
|
4264
|
+
let nodes: readonly MenuNode[] = rootNodes(context);
|
|
4265
|
+
for (const step of path) {
|
|
4266
|
+
const node = nodes.find((row) => row.kind === "menu" && row.id === step);
|
|
4267
|
+
if (node?.kind !== "menu") return undefined;
|
|
4268
|
+
labels.push(node.label);
|
|
4269
|
+
nodes = node.build(context);
|
|
4270
|
+
}
|
|
4271
|
+
return ["Ctrl-K", ...labels].join(" › ");
|
|
4272
|
+
}
|