@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- 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/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- 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 +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -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 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -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 +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -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 +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- 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/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -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/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- 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 +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- 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 +82 -32
- 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 +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- 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 +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- 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 +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- 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 +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- 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 +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
package/src/tui/menu.ts
CHANGED
|
@@ -8,19 +8,49 @@
|
|
|
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";
|
|
17
41
|
import { windParameterNodes, windsMenu } from "./wind-menu.ts";
|
|
42
|
+
import { singParameterNodes } from "./sing-menu.ts";
|
|
18
43
|
import type { FaderSpec } from "./fader.ts";
|
|
44
|
+
import { knobFields, type KnobField } from "./knob-fields.ts";
|
|
45
|
+
import { soundFamily } from "./knob-map.ts";
|
|
19
46
|
import {
|
|
20
47
|
openingMeterCommand,
|
|
21
48
|
openingUnit,
|
|
22
49
|
tempoDetail,
|
|
23
50
|
tempoMenuNode,
|
|
51
|
+
START_BARS,
|
|
52
|
+
START_BEATS_PER_BAR,
|
|
53
|
+
START_TEMPO_BPM,
|
|
24
54
|
} from "./menu-time.ts";
|
|
25
55
|
import {
|
|
26
56
|
AUTOMATION_PARAMETERS,
|
|
@@ -40,6 +70,8 @@ import {
|
|
|
40
70
|
type Track,
|
|
41
71
|
type TrackAutomationParameter,
|
|
42
72
|
type TrackScore,
|
|
73
|
+
CALIBRATION_LATEST,
|
|
74
|
+
createScore,
|
|
43
75
|
} from "../../core/score.ts";
|
|
44
76
|
import {
|
|
45
77
|
CORE_EFFECTS,
|
|
@@ -49,6 +81,7 @@ import {
|
|
|
49
81
|
RIG_STAGES,
|
|
50
82
|
rigPresetOf,
|
|
51
83
|
SHOEGAZE_EFFECTS,
|
|
84
|
+
VOWEL_VALUES,
|
|
52
85
|
effectPresetNames,
|
|
53
86
|
effectSpec,
|
|
54
87
|
type EffectName,
|
|
@@ -176,6 +209,12 @@ export type MenuContext = Readonly<{
|
|
|
176
209
|
grids: readonly string[];
|
|
177
210
|
clickOn: boolean;
|
|
178
211
|
countInBars: number;
|
|
212
|
+
/** How much agent turns show (`/showme`); absent hides the row. */
|
|
213
|
+
showMe?: string;
|
|
214
|
+
/** Project › audio: the chosen devices and the lists to pick from. */
|
|
215
|
+
audio?: MenuAudioDevices;
|
|
216
|
+
/** The session's name, for Project › session and export file names. */
|
|
217
|
+
sessionName?: string;
|
|
179
218
|
/** Play mode's chord settings (defaults when absent). */
|
|
180
219
|
chords?: ChordSettings;
|
|
181
220
|
/** Project root, for the project's own wavetables (none when absent). */
|
|
@@ -188,11 +227,25 @@ export type MenuContext = Readonly<{
|
|
|
188
227
|
audition?: MenuAudition;
|
|
189
228
|
}>;
|
|
190
229
|
|
|
230
|
+
/** One output and one input (src/audio/devices.ts); no routing. */
|
|
231
|
+
export type MenuAudioDevices = Readonly<{
|
|
232
|
+
/** `Speakers` or `default (Speakers)`. */
|
|
233
|
+
output: string;
|
|
234
|
+
input: string;
|
|
235
|
+
/** Output names; empty when the backend cannot list them. */
|
|
236
|
+
outputs: readonly string[];
|
|
237
|
+
inputs: readonly string[];
|
|
238
|
+
/** Why device choice is unavailable (fallback backends). */
|
|
239
|
+
unavailable?: string;
|
|
240
|
+
}>;
|
|
241
|
+
|
|
191
242
|
/** What the menu needs from the audition controller. */
|
|
192
243
|
export type MenuAudition = Readonly<{
|
|
193
244
|
looping: boolean;
|
|
194
245
|
/** Edits are staged (an Enter keeps them, Esc reverts them). */
|
|
195
246
|
dirty: boolean;
|
|
247
|
+
/** How many edits are staged, for the drawer's A/B badge. */
|
|
248
|
+
staged?: number;
|
|
196
249
|
/** The committed score, for `staged ← committed` on changed rows. */
|
|
197
250
|
committed: TrackScore;
|
|
198
251
|
/** The footer while auditioning. */
|
|
@@ -361,7 +414,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
|
361
414
|
resonance: "filter resonance",
|
|
362
415
|
"delay-feedback": "delay feedback",
|
|
363
416
|
"delay-mix": "delay mix",
|
|
364
|
-
wt: "
|
|
417
|
+
wt: "table position",
|
|
365
418
|
};
|
|
366
419
|
|
|
367
420
|
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
@@ -405,7 +458,9 @@ function laneLabel(lane: AutomationParameter): string {
|
|
|
405
458
|
info.effect === "keys" ||
|
|
406
459
|
info.effect === "modal" ||
|
|
407
460
|
info.effect === "grain" ||
|
|
408
|
-
info.effect === "wind"
|
|
461
|
+
info.effect === "wind" ||
|
|
462
|
+
info.effect === "sing" ||
|
|
463
|
+
info.effect === "vocoder"
|
|
409
464
|
? info.effect
|
|
410
465
|
: effectSpec(info.effect).label;
|
|
411
466
|
return `${owner} ${info.param}${unit}`;
|
|
@@ -439,9 +494,17 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
439
494
|
id: "sound",
|
|
440
495
|
label: "Sound",
|
|
441
496
|
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
442
|
-
help: "the focused track's instrument and
|
|
497
|
+
help: "the focused track's instrument, its controls and performance",
|
|
443
498
|
build: soundSectionNodes,
|
|
444
499
|
},
|
|
500
|
+
{
|
|
501
|
+
kind: "menu",
|
|
502
|
+
id: "voice",
|
|
503
|
+
label: "Voice",
|
|
504
|
+
detail: voiceRootDetail(context),
|
|
505
|
+
help: "sing, lyrics, clips, pitch, autotune, formant and vocoder",
|
|
506
|
+
build: voiceRootNodes,
|
|
507
|
+
},
|
|
445
508
|
{
|
|
446
509
|
kind: "menu",
|
|
447
510
|
id: "effects",
|
|
@@ -454,26 +517,34 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
454
517
|
kind: "menu",
|
|
455
518
|
id: "rhythm",
|
|
456
519
|
label: "Rhythm",
|
|
457
|
-
detail: "euclid editor ·
|
|
458
|
-
help: "
|
|
520
|
+
detail: "euclid editor · grooves · kits",
|
|
521
|
+
help: "hits per drum, ready-made grooves and kits",
|
|
459
522
|
build: rhythmNodes,
|
|
460
523
|
},
|
|
461
524
|
{
|
|
462
525
|
kind: "menu",
|
|
463
526
|
id: "chords",
|
|
464
|
-
label: "Chords",
|
|
527
|
+
label: "Chords and key",
|
|
465
528
|
detail: chordsDetail(context),
|
|
466
|
-
help: "song key and how chords play in play mode (Ctrl-P)",
|
|
529
|
+
help: "the song key and tuning, and how chords play in play mode (Ctrl-P)",
|
|
467
530
|
build: chordNodes,
|
|
468
531
|
},
|
|
469
532
|
{
|
|
470
533
|
kind: "menu",
|
|
471
534
|
id: "mix",
|
|
472
|
-
label: "Mix
|
|
535
|
+
label: "Mix",
|
|
473
536
|
detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
|
|
474
|
-
help: "volume, pan, mute and solo per track
|
|
537
|
+
help: "volume, pan, mute and solo per track, automation and the master",
|
|
475
538
|
build: mixSectionNodes,
|
|
476
539
|
},
|
|
540
|
+
{
|
|
541
|
+
kind: "menu",
|
|
542
|
+
id: "arrange",
|
|
543
|
+
label: "Arrange",
|
|
544
|
+
detail: arrangeDetail(context),
|
|
545
|
+
help: "tracks, song sections, the form, builds, drops, fills and styles",
|
|
546
|
+
build: arrangeRootNodes,
|
|
547
|
+
},
|
|
477
548
|
{
|
|
478
549
|
kind: "menu",
|
|
479
550
|
id: "project",
|
|
@@ -481,47 +552,94 @@ export function rootNodes(context: MenuContext): MenuNode[] {
|
|
|
481
552
|
detail: context.score.time
|
|
482
553
|
? `${tempoDetail(context.score)} · ${context.score.bars} bars`
|
|
483
554
|
: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
|
|
484
|
-
help: "tempo, meter,
|
|
555
|
+
help: "play, tempo, meter, loop, click, export, session, agent and help",
|
|
485
556
|
build: transportNodes,
|
|
486
557
|
},
|
|
487
|
-
{
|
|
488
|
-
kind: "menu",
|
|
489
|
-
id: "arrange",
|
|
490
|
-
label: "Arrange",
|
|
491
|
-
detail: arrangeDetail(context),
|
|
492
|
-
help: "song sections, the form, builds, drops and fills",
|
|
493
|
-
build: arrangeNodes,
|
|
494
|
-
},
|
|
495
558
|
];
|
|
496
559
|
}
|
|
497
560
|
|
|
498
|
-
/**
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
561
|
+
/**
|
|
562
|
+
* Where each `/menu <id>` opens: the ten topic ids (sound voice effects
|
|
563
|
+
* rhythm chords mix arrange project keys agent) first, then every older id,
|
|
564
|
+
* as a path of menu ids from the root. `keys` opens Project › help and
|
|
565
|
+
* guides; the ? panel itself belongs to the window.
|
|
566
|
+
*/
|
|
567
|
+
export const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> =
|
|
568
|
+
Object.freeze(
|
|
569
|
+
withTopicAliases({
|
|
570
|
+
sound: ["sound"],
|
|
571
|
+
voice: ["voice"],
|
|
572
|
+
effects: ["effects"],
|
|
573
|
+
rhythm: ["rhythm"],
|
|
574
|
+
chords: ["chords"],
|
|
575
|
+
mix: ["mix"],
|
|
576
|
+
arrange: ["arrange"],
|
|
577
|
+
project: ["project"],
|
|
578
|
+
keys: ["project", "help"],
|
|
579
|
+
agent: ["project", "agent"],
|
|
580
|
+
// Older ids, kept as aliases.
|
|
581
|
+
parameters: ["sound"],
|
|
582
|
+
sounds: ["sound", "browse"],
|
|
583
|
+
instruments: ["sound", "browse"],
|
|
584
|
+
performance: ["sound", "performance"],
|
|
585
|
+
expression: ["sound", "performance"],
|
|
586
|
+
clips: ["voice"],
|
|
587
|
+
lyrics: ["voice"],
|
|
588
|
+
autotune: ["voice"],
|
|
589
|
+
sing: ["voice"],
|
|
590
|
+
vocoder: ["voice"],
|
|
591
|
+
formant: ["voice"],
|
|
592
|
+
grooves: ["rhythm", "patterns"],
|
|
593
|
+
groove: ["rhythm", "patterns"],
|
|
594
|
+
patterns: ["rhythm", "patterns"],
|
|
595
|
+
kits: ["rhythm", "kits"],
|
|
596
|
+
euclid: ["rhythm"],
|
|
597
|
+
tuning: ["chords", "tuning"],
|
|
598
|
+
scale: ["chords"],
|
|
599
|
+
key: ["chords"],
|
|
600
|
+
track: ["mix"],
|
|
601
|
+
automation: ["mix", "automation"],
|
|
602
|
+
master: ["mix", "master"],
|
|
603
|
+
tracks: ["arrange", "tracks"],
|
|
604
|
+
music: ["arrange"],
|
|
605
|
+
style: ["arrange", "style"],
|
|
606
|
+
styles: ["arrange", "style"],
|
|
607
|
+
genre: ["arrange", "style"],
|
|
608
|
+
sections: ["arrange", "sections"],
|
|
609
|
+
form: ["arrange"],
|
|
610
|
+
transport: ["project"],
|
|
611
|
+
tempo: ["project", "tempo"],
|
|
612
|
+
meter: ["project", "tempo"],
|
|
613
|
+
time: ["project", "tempo"],
|
|
614
|
+
export: ["project", "export"],
|
|
615
|
+
session: ["project", "session"],
|
|
616
|
+
sessions: ["project", "session"],
|
|
617
|
+
window: ["project", "help"],
|
|
618
|
+
help: ["project", "help"],
|
|
619
|
+
guides: ["project", "help"],
|
|
620
|
+
model: ["project", "agent"],
|
|
621
|
+
audio: ["project", "audio"],
|
|
622
|
+
devices: ["project", "audio"],
|
|
623
|
+
showme: ["project", "agent"],
|
|
624
|
+
models: ["project", "agent"],
|
|
625
|
+
fx: ["effects"],
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Every glossary topic alias (`/help drums`, `/guide mixer`) opens the same
|
|
631
|
+
* topic in `/menu` too: a word with no deeper path here opens its topic.
|
|
632
|
+
*/
|
|
633
|
+
function withTopicAliases(
|
|
634
|
+
paths: Record<string, readonly string[]>,
|
|
635
|
+
): Record<string, readonly string[]> {
|
|
636
|
+
const all = { ...paths };
|
|
637
|
+
for (const [word, topic] of Object.entries(TOPIC_ALIASES))
|
|
638
|
+
if (!all[word] && all[topic]) all[word] = all[topic]!;
|
|
639
|
+
return all;
|
|
640
|
+
}
|
|
523
641
|
|
|
524
|
-
/** Sound: instrument and
|
|
642
|
+
/** Sound: the instrument and its controls first, then the instruments. */
|
|
525
643
|
function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
526
644
|
const track = focused(context);
|
|
527
645
|
const tuning: MenuNode[] =
|
|
@@ -530,7 +648,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
530
648
|
{
|
|
531
649
|
kind: "menu",
|
|
532
650
|
id: "track-tuning",
|
|
533
|
-
label: "tuning",
|
|
651
|
+
label: "track tuning",
|
|
534
652
|
detail: track.tuning
|
|
535
653
|
? tuningLabel(track.tuning)
|
|
536
654
|
: `song · ${tuningLabel(context.score.tuning)}`,
|
|
@@ -561,7 +679,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
561
679
|
id: "keys",
|
|
562
680
|
label: "keys",
|
|
563
681
|
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
564
|
-
help: "the
|
|
682
|
+
help: "the modeled organ: preset, drawbars, registers or stops, rotary",
|
|
565
683
|
build: (inner) => {
|
|
566
684
|
const current = focused(inner);
|
|
567
685
|
return current ? organNodes(current) : [];
|
|
@@ -577,7 +695,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
577
695
|
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
578
696
|
help: isElectricFamily(track.instrument)
|
|
579
697
|
? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
|
|
580
|
-
: "the
|
|
698
|
+
: "the modeled piano: preset, touch, hammers, dampers, stretch",
|
|
581
699
|
build: (inner) => {
|
|
582
700
|
const current = focused(inner);
|
|
583
701
|
return current
|
|
@@ -622,9 +740,9 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
622
740
|
{
|
|
623
741
|
kind: "menu",
|
|
624
742
|
id: "browse",
|
|
625
|
-
label: "
|
|
743
|
+
label: "instruments",
|
|
626
744
|
detail: soundsDetail(focused(context)),
|
|
627
|
-
help: "
|
|
745
|
+
help: "every instrument: keys, strings, mallets, winds, granular, wavetables, sample packs",
|
|
628
746
|
build: soundNodes,
|
|
629
747
|
},
|
|
630
748
|
];
|
|
@@ -709,20 +827,262 @@ function guitarNodes(track: Track | undefined): MenuNode[] {
|
|
|
709
827
|
}
|
|
710
828
|
|
|
711
829
|
/** Rhythm: the euclid editor, then grooves and kits. */
|
|
712
|
-
function rhythmNodes(): MenuNode[] {
|
|
830
|
+
function rhythmNodes(context: MenuContext): MenuNode[] {
|
|
713
831
|
return [
|
|
714
832
|
{
|
|
715
833
|
kind: "action",
|
|
716
834
|
label: "euclid editor",
|
|
717
835
|
command: "/euclid",
|
|
718
|
-
help: "hits, steps and rotation per drum
|
|
836
|
+
help: "hits, steps and rotation per drum",
|
|
719
837
|
},
|
|
720
838
|
patternsMenu(),
|
|
721
839
|
kitsMenu(),
|
|
840
|
+
gridNode(context),
|
|
722
841
|
];
|
|
723
842
|
}
|
|
724
843
|
|
|
725
|
-
/**
|
|
844
|
+
/** The record and euclid grid (Rhythm and Project share this row). */
|
|
845
|
+
function gridNode(context: MenuContext): MenuNode {
|
|
846
|
+
return {
|
|
847
|
+
kind: "choice",
|
|
848
|
+
label: "grid",
|
|
849
|
+
help: "the step play-mode recording and the euclid editor snap to",
|
|
850
|
+
value: context.grid,
|
|
851
|
+
options: context.grids,
|
|
852
|
+
command: (option) => `/grid ${option}`,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Arrange: the tracks first, then sections, form and styles. */
|
|
857
|
+
function arrangeRootNodes(context: MenuContext): MenuNode[] {
|
|
858
|
+
const count = context.score.tracks.length;
|
|
859
|
+
return [
|
|
860
|
+
{
|
|
861
|
+
kind: "menu",
|
|
862
|
+
id: "tracks",
|
|
863
|
+
label: "tracks",
|
|
864
|
+
detail: `${count} track${count === 1 ? "" : "s"} · add, focus, move, remove`,
|
|
865
|
+
help: "add a track, focus one, rename, reorder or remove it",
|
|
866
|
+
build: tracksNodes,
|
|
867
|
+
},
|
|
868
|
+
...arrangeNodes(context),
|
|
869
|
+
];
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Arrange › tracks: add, focus, rename, move and remove. */
|
|
873
|
+
function tracksNodes(context: MenuContext): MenuNode[] {
|
|
874
|
+
const score = context.score;
|
|
875
|
+
const track = focused(context);
|
|
876
|
+
const nodes: MenuNode[] = [
|
|
877
|
+
{
|
|
878
|
+
kind: "entry",
|
|
879
|
+
label: "add a track",
|
|
880
|
+
value: "",
|
|
881
|
+
placeholder: "name, e.g. bass or piano b",
|
|
882
|
+
example: "/track bass",
|
|
883
|
+
help: "a new track with that name (or focus it when it exists)",
|
|
884
|
+
command: (text) => {
|
|
885
|
+
const name = text.trim().replace(/\s+/g, " ");
|
|
886
|
+
return /^[a-z0-9._ -]{1,64}$/i.test(name)
|
|
887
|
+
? `/track ${name}`
|
|
888
|
+
: undefined;
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
...score.tracks.map((item): MenuNode => ({
|
|
892
|
+
kind: "action",
|
|
893
|
+
label: `${item.id === context.trackId ? "● " : ""}${item.name ?? item.id}`,
|
|
894
|
+
command: `/track ${item.id}`,
|
|
895
|
+
help: `focus ${item.id} · ${item.instrument}`,
|
|
896
|
+
})),
|
|
897
|
+
];
|
|
898
|
+
if (track) {
|
|
899
|
+
nodes.push({
|
|
900
|
+
kind: "entry",
|
|
901
|
+
label: "rename",
|
|
902
|
+
value: track.name,
|
|
903
|
+
placeholder: "new name",
|
|
904
|
+
command: (text) =>
|
|
905
|
+
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
906
|
+
example: `track name ${track.name}`,
|
|
907
|
+
help: "rename the focused track",
|
|
908
|
+
});
|
|
909
|
+
if (score.tracks.length > 1) {
|
|
910
|
+
const index = score.tracks.findIndex((item) => item.id === track.id);
|
|
911
|
+
nodes.push(
|
|
912
|
+
{
|
|
913
|
+
kind: "number",
|
|
914
|
+
label: "position",
|
|
915
|
+
help: "where the focused track sits in the list",
|
|
916
|
+
value: index + 1,
|
|
917
|
+
min: 1,
|
|
918
|
+
max: score.tracks.length,
|
|
919
|
+
step: linear(1, 1, score.tracks.length),
|
|
920
|
+
format: (value) => `${num(value)} of ${score.tracks.length}`,
|
|
921
|
+
command: (value) => `/track move ${track.id} ${Math.round(value)}`,
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
kind: "action",
|
|
925
|
+
label: `remove ${track.id}`,
|
|
926
|
+
command: `/track remove ${track.id}`,
|
|
927
|
+
help: "remove the focused track (^z brings it back)",
|
|
928
|
+
},
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return nodes;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** A file-safe stem for export names: the session's song name or `song`. */
|
|
936
|
+
function exportStem(context: MenuContext): string {
|
|
937
|
+
const stem = (context.sessionName ?? "song")
|
|
938
|
+
.toLowerCase()
|
|
939
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
940
|
+
.replace(/^-+|-+$/g, "")
|
|
941
|
+
.slice(0, 40);
|
|
942
|
+
return stem || "song";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Project › export: the project file and MIDI; audio renders offline. */
|
|
946
|
+
function exportNodes(context: MenuContext): MenuNode[] {
|
|
947
|
+
const stem = exportStem(context);
|
|
948
|
+
return [
|
|
949
|
+
{
|
|
950
|
+
kind: "entry",
|
|
951
|
+
label: "project file",
|
|
952
|
+
value: "",
|
|
953
|
+
placeholder: `file name, e.g. ${stem}.track.json`,
|
|
954
|
+
example: `/export ${stem}.track.json`,
|
|
955
|
+
help: "the whole song as one .track.json file (open it with /import)",
|
|
956
|
+
command: (text) => {
|
|
957
|
+
const name = text.trim() || `${stem}.track.json`;
|
|
958
|
+
return /^\S+$/.test(name)
|
|
959
|
+
? `/export ${/\.json$/i.test(name) ? name : `${name}.track.json`}`
|
|
960
|
+
: undefined;
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
kind: "entry",
|
|
965
|
+
label: "MIDI",
|
|
966
|
+
value: "",
|
|
967
|
+
placeholder: `file name, e.g. ${stem}.mid`,
|
|
968
|
+
example: `/export ${stem}.mid`,
|
|
969
|
+
help: "notes, tempo and tracks as a standard MIDI file",
|
|
970
|
+
command: (text) => {
|
|
971
|
+
const name = text.trim() || `${stem}.mid`;
|
|
972
|
+
return /^\S+$/.test(name)
|
|
973
|
+
? `/export ${/\.midi?$/i.test(name) ? name : `${name}.mid`}`
|
|
974
|
+
: undefined;
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
kind: "info",
|
|
979
|
+
label: "WAV and stems",
|
|
980
|
+
value: `dawg render ${stem}.wav`,
|
|
981
|
+
help: "audio renders offline in the shell: dawg render out.wav (--stems for one file per track)",
|
|
982
|
+
},
|
|
983
|
+
];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Project › session: rename, fork and resume (the window's verbs). */
|
|
987
|
+
function sessionNodes(context: MenuContext): MenuNode[] {
|
|
988
|
+
return [
|
|
989
|
+
{
|
|
990
|
+
kind: "entry",
|
|
991
|
+
label: "rename",
|
|
992
|
+
value: context.sessionName ?? "",
|
|
993
|
+
placeholder: "song name (--auto names it for you)",
|
|
994
|
+
example: "/rename night drive",
|
|
995
|
+
help: "name this session; --auto hands naming back to dawg",
|
|
996
|
+
command: (text) => (text.trim() ? `/rename ${text.trim()}` : undefined),
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
kind: "action",
|
|
1000
|
+
label: "fork",
|
|
1001
|
+
command: "/fork",
|
|
1002
|
+
help: "a copy of this session to try something; the original stays",
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
kind: "action",
|
|
1006
|
+
label: "resume",
|
|
1007
|
+
command: "/resume",
|
|
1008
|
+
help: "pick an earlier session in this folder",
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
kind: "action",
|
|
1012
|
+
label: "list sessions",
|
|
1013
|
+
command: "/sessions",
|
|
1014
|
+
help: "every session in this folder, newest first",
|
|
1015
|
+
},
|
|
1016
|
+
];
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Project › agent: the model, show-me and the model key. */
|
|
1020
|
+
function agentNodes(context: MenuContext): MenuNode[] {
|
|
1021
|
+
return [
|
|
1022
|
+
{
|
|
1023
|
+
kind: "action",
|
|
1024
|
+
label: "model",
|
|
1025
|
+
command: "/model",
|
|
1026
|
+
help: "pick the model the agent runs on",
|
|
1027
|
+
},
|
|
1028
|
+
...(context.showMe === undefined
|
|
1029
|
+
? []
|
|
1030
|
+
: [
|
|
1031
|
+
{
|
|
1032
|
+
kind: "choice" as const,
|
|
1033
|
+
label: "show me",
|
|
1034
|
+
help: "agent turns: its commands as ghost text, faders, keys",
|
|
1035
|
+
value: context.showMe,
|
|
1036
|
+
options: ["on", "quiet", "off"],
|
|
1037
|
+
command: (option: string) => `/showme ${option}`,
|
|
1038
|
+
},
|
|
1039
|
+
]),
|
|
1040
|
+
{
|
|
1041
|
+
kind: "action",
|
|
1042
|
+
label: "model key",
|
|
1043
|
+
command: "/model key",
|
|
1044
|
+
help: "add a model key so typed requests reach the agent (dawg works offline without it)",
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
kind: "action",
|
|
1048
|
+
label: "agent help",
|
|
1049
|
+
command: "/help agent",
|
|
1050
|
+
help: "what the agent can do and how its turns show",
|
|
1051
|
+
},
|
|
1052
|
+
];
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Project › help and guides: the reference, guides and keys. */
|
|
1056
|
+
function helpNodes(): MenuNode[] {
|
|
1057
|
+
return [
|
|
1058
|
+
{
|
|
1059
|
+
kind: "action",
|
|
1060
|
+
label: "help",
|
|
1061
|
+
command: "/help",
|
|
1062
|
+
help: "start here: the ten topics",
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
kind: "action",
|
|
1066
|
+
label: "all commands",
|
|
1067
|
+
command: "/help all",
|
|
1068
|
+
help: "the full command reference",
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
kind: "action",
|
|
1072
|
+
label: "guides",
|
|
1073
|
+
command: "/guide",
|
|
1074
|
+
help: "short walkthroughs, one screen each",
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
kind: "action",
|
|
1078
|
+
label: "keys",
|
|
1079
|
+
command: "/help keys",
|
|
1080
|
+
help: "every key in the window (? shows them too)",
|
|
1081
|
+
},
|
|
1082
|
+
];
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Mix: this track, every track, automation lanes, then the master. */
|
|
726
1086
|
function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
727
1087
|
const track = focused(context);
|
|
728
1088
|
const automated = AUTOMATION_PARAMETERS.filter(
|
|
@@ -730,6 +1090,14 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
730
1090
|
).join(" ");
|
|
731
1091
|
return [
|
|
732
1092
|
...trackNodes(context),
|
|
1093
|
+
{
|
|
1094
|
+
kind: "menu",
|
|
1095
|
+
id: "mixer",
|
|
1096
|
+
label: "mixer",
|
|
1097
|
+
detail: `${context.score.tracks.length} levels on one page`,
|
|
1098
|
+
help: "every track's level as a fader, the drawer's all-tracks page (mix) · tab flips to this track",
|
|
1099
|
+
build: mixerNodes,
|
|
1100
|
+
},
|
|
733
1101
|
{
|
|
734
1102
|
kind: "menu",
|
|
735
1103
|
id: "tracks",
|
|
@@ -759,7 +1127,7 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
759
1127
|
];
|
|
760
1128
|
}
|
|
761
1129
|
|
|
762
|
-
/** Mix
|
|
1130
|
+
/** Mix › master: the loudness target, then each unit in order. */
|
|
763
1131
|
function masterNodes(context: MenuContext): MenuNode[] {
|
|
764
1132
|
const master = context.score.master;
|
|
765
1133
|
// A name only when the LUFS and the limiter ceiling both match it, and no
|
|
@@ -932,7 +1300,7 @@ function chordsDetail(context: MenuContext): string {
|
|
|
932
1300
|
return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
|
|
933
1301
|
}
|
|
934
1302
|
|
|
935
|
-
/**
|
|
1303
|
+
/** Chords and key: the song key and tuning, then play mode's chord settings. */
|
|
936
1304
|
function chordNodes(context: MenuContext): MenuNode[] {
|
|
937
1305
|
const chords = context.chords ?? defaultChordSettings();
|
|
938
1306
|
const key = parseKey(context.score.key ?? undefined);
|
|
@@ -965,11 +1333,20 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
965
1333
|
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
966
1334
|
command: (option) => `key ${tonic} ${option}`,
|
|
967
1335
|
},
|
|
1336
|
+
{
|
|
1337
|
+
kind: "menu",
|
|
1338
|
+
id: "tuning",
|
|
1339
|
+
label: "tuning",
|
|
1340
|
+
detail: `${tuningLabel(context.score.tuning)} · ${keyLabel(context.score.key)}`,
|
|
1341
|
+
help: "the song tuning: 12-TET, EDOs, just, gamelan, Scala, and the scale",
|
|
1342
|
+
build: songTuningNodes,
|
|
1343
|
+
},
|
|
968
1344
|
{
|
|
969
1345
|
kind: "number",
|
|
970
1346
|
label: "voicing",
|
|
971
1347
|
help: "inversion steps up or down (play mode: - =)",
|
|
972
1348
|
value: chords.inversion,
|
|
1349
|
+
start: 0,
|
|
973
1350
|
min: -MAX_VOICING_STEP,
|
|
974
1351
|
max: MAX_VOICING_STEP,
|
|
975
1352
|
step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
|
|
@@ -1056,6 +1433,7 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
1056
1433
|
label: "arp octaves",
|
|
1057
1434
|
help: "how many octaves an arpeggio climbs",
|
|
1058
1435
|
value: chords.octaves,
|
|
1436
|
+
start: 1,
|
|
1059
1437
|
min: 1,
|
|
1060
1438
|
max: 4,
|
|
1061
1439
|
step: linear(1, 1, 4),
|
|
@@ -1072,12 +1450,22 @@ function chordNodes(context: MenuContext): MenuNode[] {
|
|
|
1072
1450
|
},
|
|
1073
1451
|
{
|
|
1074
1452
|
kind: "choice",
|
|
1075
|
-
label: "
|
|
1076
|
-
help: "the
|
|
1453
|
+
label: "idiom",
|
|
1454
|
+
help: "the musical idiom n suggests next chords in (pop, jazz, blues…)",
|
|
1077
1455
|
value: chords.style,
|
|
1078
1456
|
options: PROGRESSION_STYLES,
|
|
1079
1457
|
command: (option) => `/chords style ${option}`,
|
|
1080
1458
|
},
|
|
1459
|
+
{
|
|
1460
|
+
kind: "entry",
|
|
1461
|
+
label: "progression",
|
|
1462
|
+
value: "",
|
|
1463
|
+
placeholder: "chords, e.g. i7 IV7 each 8",
|
|
1464
|
+
help: "sustained, voice-led block chords on this track in the song key",
|
|
1465
|
+
command: (text) =>
|
|
1466
|
+
text.trim() ? `progression ${text.trim()}` : undefined,
|
|
1467
|
+
example: "progression i7 IV7 each 8",
|
|
1468
|
+
},
|
|
1081
1469
|
];
|
|
1082
1470
|
}
|
|
1083
1471
|
|
|
@@ -1130,6 +1518,24 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
1130
1518
|
];
|
|
1131
1519
|
}
|
|
1132
1520
|
|
|
1521
|
+
/**
|
|
1522
|
+
* Mix › mixer: one level fader per track (design §8.5), each a typed
|
|
1523
|
+
* `volume <track> <0..1>`, so the page sets any track without moving the
|
|
1524
|
+
* focus. Labels are numbered, so two tracks with one name stay apart.
|
|
1525
|
+
*/
|
|
1526
|
+
export function mixerNodes(context: MenuContext): MenuNode[] {
|
|
1527
|
+
return context.score.tracks.map((track, index): MenuNode => {
|
|
1528
|
+
const base = volumeNode(track);
|
|
1529
|
+
return {
|
|
1530
|
+
...(base as Extract<MenuNode, { kind: "number" }>),
|
|
1531
|
+
label: `${index + 1}${track.id === context.trackId ? "›" : " "}${track.name}`,
|
|
1532
|
+
command: (value: number) => `volume ${track.id} ${num(value)}`,
|
|
1533
|
+
reset: `volume ${track.id} 1`,
|
|
1534
|
+
help: `${track.name}'s level; 1 is unity (0 dB) · x resets`,
|
|
1535
|
+
};
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1133
1539
|
function instrumentNode(track: Track): MenuNode {
|
|
1134
1540
|
const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
|
|
1135
1541
|
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
@@ -1173,7 +1579,7 @@ function panNode(track: Track): MenuNode {
|
|
|
1173
1579
|
: `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
|
|
1174
1580
|
command: (value) => `pan ${num(value)}`,
|
|
1175
1581
|
reset: "pan 0",
|
|
1176
|
-
help: "-1 left … 1 right · x
|
|
1582
|
+
help: "-1 left … 1 right · x centers",
|
|
1177
1583
|
};
|
|
1178
1584
|
}
|
|
1179
1585
|
|
|
@@ -1256,7 +1662,7 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
1256
1662
|
detail: `all ${all.length} params`,
|
|
1257
1663
|
help: isElectricFamily(track.instrument)
|
|
1258
1664
|
? "every electric keys parameter for this family"
|
|
1259
|
-
: "every
|
|
1665
|
+
: "every modeled piano parameter",
|
|
1260
1666
|
build: (inner) => {
|
|
1261
1667
|
const current = focused(inner);
|
|
1262
1668
|
return current
|
|
@@ -1270,6 +1676,11 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
1270
1676
|
} else if (track.instrument === "wind" && track.wind) {
|
|
1271
1677
|
// 0.6.1 wind engine: preset and WIND_PARAMS rows.
|
|
1272
1678
|
nodes.push(...windParameterNodes(track));
|
|
1679
|
+
} else if (track.instrument === "sing" && track.sing) {
|
|
1680
|
+
// 0.7 sing engine: preset, SING_PARAMS rows and a Throat sub-menu.
|
|
1681
|
+
nodes.push(
|
|
1682
|
+
...singParameterNodes(track, parseKey(context.score.key)?.tonic),
|
|
1683
|
+
);
|
|
1273
1684
|
} else if (!isDrumInstrument(track.instrument)) {
|
|
1274
1685
|
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
1275
1686
|
nodes.push({
|
|
@@ -1484,7 +1895,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
|
1484
1895
|
// 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
|
|
1485
1896
|
{
|
|
1486
1897
|
kind: "choice",
|
|
1487
|
-
label: "
|
|
1898
|
+
label: "velocity layer",
|
|
1488
1899
|
value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
|
|
1489
1900
|
options: velocityLayerOptions(ref.vel),
|
|
1490
1901
|
command: (option) => set(`vel ${option}`),
|
|
@@ -1492,7 +1903,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
|
1492
1903
|
},
|
|
1493
1904
|
{
|
|
1494
1905
|
kind: "entry",
|
|
1495
|
-
label: "
|
|
1906
|
+
label: "round robin",
|
|
1496
1907
|
value: ref.rr ?? "",
|
|
1497
1908
|
placeholder: "group name, e.g. sn",
|
|
1498
1909
|
command: (text) => set(`rr ${text.trim() || "off"}`),
|
|
@@ -1524,7 +1935,7 @@ function matchingSynthPreset(track: Track): string | undefined {
|
|
|
1524
1935
|
)?.[0];
|
|
1525
1936
|
}
|
|
1526
1937
|
|
|
1527
|
-
/**
|
|
1938
|
+
/** Modeled piano rows: the preset, then each parameter (`keys <p> <v>`). */
|
|
1528
1939
|
function keysNodes(
|
|
1529
1940
|
track: Track,
|
|
1530
1941
|
params: readonly string[],
|
|
@@ -1702,7 +2113,7 @@ function organNodes(track: Track): MenuNode[] {
|
|
|
1702
2113
|
? {
|
|
1703
2114
|
kind: "menu",
|
|
1704
2115
|
id: "keys:drawbars",
|
|
1705
|
-
label: "
|
|
2116
|
+
label: "drawbars",
|
|
1706
2117
|
detail: text("drawbars"),
|
|
1707
2118
|
help: "nine drawbars 16' to 1' (0 in, 8 full out)",
|
|
1708
2119
|
build: (inner) => {
|
|
@@ -1720,7 +2131,7 @@ function organNodes(track: Track): MenuNode[] {
|
|
|
1720
2131
|
? {
|
|
1721
2132
|
kind: "menu",
|
|
1722
2133
|
id: "keys:registers",
|
|
1723
|
-
label: "
|
|
2134
|
+
label: "registers",
|
|
1724
2135
|
detail: text("registers"),
|
|
1725
2136
|
help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
|
|
1726
2137
|
build: (inner) => {
|
|
@@ -1737,7 +2148,7 @@ function organNodes(track: Track): MenuNode[] {
|
|
|
1737
2148
|
: {
|
|
1738
2149
|
kind: "menu",
|
|
1739
2150
|
id: "keys:stops",
|
|
1740
|
-
label: "
|
|
2151
|
+
label: "stops",
|
|
1741
2152
|
detail: text("stops"),
|
|
1742
2153
|
help: "pipe stops and registrations",
|
|
1743
2154
|
build: (inner) => {
|
|
@@ -1814,9 +2225,9 @@ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
|
|
|
1814
2225
|
|
|
1815
2226
|
/** Plain names for the simple synth rows; the note shows the command name. */
|
|
1816
2227
|
const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
1817
|
-
lpf: "filter
|
|
1818
|
-
lpq: "filter res",
|
|
1819
|
-
lpenv: "filter env",
|
|
2228
|
+
lpf: "synth filter",
|
|
2229
|
+
lpq: "synth filter res",
|
|
2230
|
+
lpenv: "synth filter env",
|
|
1820
2231
|
vib: "vibrato",
|
|
1821
2232
|
fm: "FM amount",
|
|
1822
2233
|
};
|
|
@@ -2000,7 +2411,7 @@ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
|
|
|
2000
2411
|
nodes.push({
|
|
2001
2412
|
kind: "info",
|
|
2002
2413
|
label: "position automation",
|
|
2003
|
-
value: `${track.wtAutomation!.length} points · Automation ›
|
|
2414
|
+
value: `${track.wtAutomation!.length} points · Automation › table position`,
|
|
2004
2415
|
});
|
|
2005
2416
|
return nodes;
|
|
2006
2417
|
}
|
|
@@ -2015,7 +2426,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
2015
2426
|
return {
|
|
2016
2427
|
kind: "menu",
|
|
2017
2428
|
id: effect,
|
|
2018
|
-
label: spec.label
|
|
2429
|
+
label: spec.label,
|
|
2019
2430
|
detail: values ? effectSummary(effect, values) : "off",
|
|
2020
2431
|
help: spec.doc,
|
|
2021
2432
|
build: (inner) => effectParamNodes(inner, effect, false),
|
|
@@ -2028,7 +2439,9 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
2028
2439
|
(effect) =>
|
|
2029
2440
|
!CORE_EFFECTS.includes(effect) &&
|
|
2030
2441
|
!rigStages.includes(effect) &&
|
|
2031
|
-
!gaze.includes(effect)
|
|
2442
|
+
!gaze.includes(effect) &&
|
|
2443
|
+
// 0.7: the formant shift lives in Effects > Voice.
|
|
2444
|
+
effect !== "formant",
|
|
2032
2445
|
);
|
|
2033
2446
|
const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
|
|
2034
2447
|
effectValues(track, effect),
|
|
@@ -2036,7 +2449,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
2036
2449
|
const shoegaze: MenuNode = {
|
|
2037
2450
|
kind: "menu",
|
|
2038
2451
|
id: "shoegaze",
|
|
2039
|
-
label: "
|
|
2452
|
+
label: "shoegaze",
|
|
2040
2453
|
help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
|
|
2041
2454
|
detail:
|
|
2042
2455
|
gazeOn.length === 0
|
|
@@ -2050,7 +2463,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
2050
2463
|
const rig: MenuNode = {
|
|
2051
2464
|
kind: "menu",
|
|
2052
2465
|
id: "guitar rig",
|
|
2053
|
-
label: "
|
|
2466
|
+
label: "guitar rig",
|
|
2054
2467
|
help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
|
|
2055
2468
|
detail:
|
|
2056
2469
|
rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
|
|
@@ -2073,6 +2486,19 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
2073
2486
|
...CORE_EFFECTS.map(node),
|
|
2074
2487
|
rig,
|
|
2075
2488
|
shoegaze,
|
|
2489
|
+
// Formant and vocoder live under Voice; this row is the way there.
|
|
2490
|
+
...(voiceEffectRows(context).length > 0
|
|
2491
|
+
? [
|
|
2492
|
+
{
|
|
2493
|
+
kind: "menu" as const,
|
|
2494
|
+
id: "voice",
|
|
2495
|
+
label: "voice effects",
|
|
2496
|
+
detail: "formant · vocoder · also in Voice",
|
|
2497
|
+
help: "formant and vocoder for voices (the same rows as Ctrl-K › Voice)",
|
|
2498
|
+
build: voiceEffectRows,
|
|
2499
|
+
},
|
|
2500
|
+
]
|
|
2501
|
+
: []),
|
|
2076
2502
|
{
|
|
2077
2503
|
kind: "menu",
|
|
2078
2504
|
id: "more effects",
|
|
@@ -2109,16 +2535,17 @@ function withUnit(text: string, unit: string | undefined): string {
|
|
|
2109
2535
|
|
|
2110
2536
|
function formatParam(spec: ParamSpec, value: number): string {
|
|
2111
2537
|
if (spec.kind !== "number") return num(value);
|
|
2112
|
-
|
|
2113
|
-
? `${Math.round(value)}`
|
|
2114
|
-
|
|
2538
|
+
const text =
|
|
2539
|
+
spec.step === "log" && value >= 100 ? `${Math.round(value)}` : num(value);
|
|
2540
|
+
// Rounding never leaves the range: attack 0.0001 types as 0.0001, not 0.
|
|
2541
|
+
return Number(text) < spec.min ? String(spec.min) : text;
|
|
2115
2542
|
}
|
|
2116
2543
|
|
|
2117
2544
|
/**
|
|
2118
2545
|
* One effect's rows: on/off, presets, then its simple parameters; the
|
|
2119
2546
|
* `advanced` submenu holds every parameter with its Strudel names.
|
|
2120
2547
|
*/
|
|
2121
|
-
function effectParamNodes(
|
|
2548
|
+
export function effectParamNodes(
|
|
2122
2549
|
context: MenuContext,
|
|
2123
2550
|
effect: EffectName,
|
|
2124
2551
|
advanced: boolean,
|
|
@@ -2209,6 +2636,37 @@ function effectParamNodes(
|
|
|
2209
2636
|
});
|
|
2210
2637
|
}
|
|
2211
2638
|
}
|
|
2639
|
+
if (!advanced && effect === "vowel") {
|
|
2640
|
+
// 0.7 vowel morph: To picks the target (morph starts halfway), Morph
|
|
2641
|
+
// moves between the two; both through `/vowel`.
|
|
2642
|
+
const to = values?.to;
|
|
2643
|
+
nodes.push(
|
|
2644
|
+
{
|
|
2645
|
+
kind: "choice",
|
|
2646
|
+
label: "to",
|
|
2647
|
+
value: typeof to === "string" ? to : "—",
|
|
2648
|
+
options: ["—", ...VOWEL_VALUES],
|
|
2649
|
+
command: (option) =>
|
|
2650
|
+
option === "—" ? "/vowel to off" : `/vowel to ${option}`,
|
|
2651
|
+
help: "a second vowel to morph toward (— keeps one vowel)",
|
|
2652
|
+
},
|
|
2653
|
+
{
|
|
2654
|
+
kind: "number",
|
|
2655
|
+
label: "morph",
|
|
2656
|
+
value: typeof values?.morph === "number" ? values.morph : undefined,
|
|
2657
|
+
start: 0.5,
|
|
2658
|
+
off: typeof to === "string" ? "0" : "set to first",
|
|
2659
|
+
min: 0,
|
|
2660
|
+
max: 1,
|
|
2661
|
+
step: specStep(spec.params.morph as NumberParam),
|
|
2662
|
+
format: (value) => num(value),
|
|
2663
|
+
command: (value) =>
|
|
2664
|
+
typeof to === "string" ? `/vowel morph ${num(value)}` : `/vowel to o`,
|
|
2665
|
+
help: "0 is the vowel, 1 is the to vowel; log-frequency formant glide, automatable as vowel-morph",
|
|
2666
|
+
...(typeof to === "string" ? { reset: "/vowel morph 0" } : {}),
|
|
2667
|
+
},
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2212
2670
|
if (!advanced) {
|
|
2213
2671
|
nodes.push({
|
|
2214
2672
|
kind: "menu",
|
|
@@ -2251,6 +2709,8 @@ function automationNodes(context: MenuContext): MenuNode[] {
|
|
|
2251
2709
|
if (info?.effect === "modal") return track.modal !== undefined;
|
|
2252
2710
|
if (info?.effect === "grain") return track.granular !== undefined;
|
|
2253
2711
|
if (info?.effect === "wind") return track.wind !== undefined;
|
|
2712
|
+
if (info?.effect === "sing") return track.sing !== undefined;
|
|
2713
|
+
if (info?.effect === "vocoder") return track.vocoder !== undefined;
|
|
2254
2714
|
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
2255
2715
|
});
|
|
2256
2716
|
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
@@ -2376,14 +2836,14 @@ function soundsDetail(track: Track | undefined): string {
|
|
|
2376
2836
|
}
|
|
2377
2837
|
|
|
2378
2838
|
/**
|
|
2379
|
-
* The instrument browser:
|
|
2839
|
+
* The instrument browser: kits and soundfont instruments from the
|
|
2380
2840
|
* built-in packs (fetched on first use), and the pack list.
|
|
2381
2841
|
*/
|
|
2382
2842
|
function kitsMenu(): MenuNode {
|
|
2383
2843
|
return {
|
|
2384
2844
|
kind: "menu",
|
|
2385
2845
|
id: "kits",
|
|
2386
|
-
label: "
|
|
2846
|
+
label: "kits",
|
|
2387
2847
|
help: "make this a drum track with a synth or sample kit",
|
|
2388
2848
|
detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
|
|
2389
2849
|
// Synth kits first (offline), then the pack sample kits.
|
|
@@ -2420,27 +2880,27 @@ function patternsMenu(): MenuNode {
|
|
|
2420
2880
|
return {
|
|
2421
2881
|
kind: "menu",
|
|
2422
2882
|
id: "patterns",
|
|
2423
|
-
label: "
|
|
2424
|
-
detail: `${DRUM_PATTERNS.length}
|
|
2883
|
+
label: "grooves",
|
|
2884
|
+
detail: `${DRUM_PATTERNS.length} ready-made drum parts`,
|
|
2425
2885
|
help: "replace the drum part with a ready-made groove",
|
|
2426
2886
|
build: () =>
|
|
2427
2887
|
DRUM_PATTERNS.map((entry): MenuNode => ({
|
|
2428
2888
|
kind: "action",
|
|
2429
2889
|
label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
2430
|
-
command: `/
|
|
2890
|
+
command: `/groove ${entry.name}`,
|
|
2431
2891
|
})),
|
|
2432
2892
|
};
|
|
2433
2893
|
}
|
|
2434
2894
|
|
|
2435
2895
|
const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
|
|
2436
2896
|
|
|
2437
|
-
function soundNodes(): MenuNode[] {
|
|
2897
|
+
function soundNodes(context: MenuContext): MenuNode[] {
|
|
2438
2898
|
return [
|
|
2439
2899
|
{
|
|
2440
2900
|
kind: "menu",
|
|
2441
2901
|
id: "group:keys",
|
|
2442
|
-
label: "
|
|
2443
|
-
help: "
|
|
2902
|
+
label: "keys",
|
|
2903
|
+
help: "modeled pianos, electric keys and organs, built in (no download)",
|
|
2444
2904
|
detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
|
|
2445
2905
|
build: () => [
|
|
2446
2906
|
...PIANO_PRESET_NAMES.map((name): MenuNode => {
|
|
@@ -2456,8 +2916,8 @@ function soundNodes(): MenuNode[] {
|
|
|
2456
2916
|
{
|
|
2457
2917
|
kind: "menu",
|
|
2458
2918
|
id: "group:keys:electric",
|
|
2459
|
-
label: "
|
|
2460
|
-
help: "electric pianos (tine, reed) and the clavinet,
|
|
2919
|
+
label: "electric",
|
|
2920
|
+
help: "electric pianos (tine, reed) and the clavinet, modeled",
|
|
2461
2921
|
detail: ELECTRIC_PRESET_NAMES.join(" "),
|
|
2462
2922
|
build: () =>
|
|
2463
2923
|
Object.entries(KEYS_PRESETS)
|
|
@@ -2473,7 +2933,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2473
2933
|
{
|
|
2474
2934
|
kind: "menu",
|
|
2475
2935
|
id: "group:organs",
|
|
2476
|
-
label: "
|
|
2936
|
+
label: "organs",
|
|
2477
2937
|
help: "tonewheel, combo and pipe organs on the keys engine",
|
|
2478
2938
|
detail: ORGAN_PRESET_NAMES.join(" "),
|
|
2479
2939
|
build: () =>
|
|
@@ -2491,7 +2951,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2491
2951
|
},
|
|
2492
2952
|
{
|
|
2493
2953
|
kind: "action",
|
|
2494
|
-
label: "wavetable
|
|
2954
|
+
label: "wavetable basic shapes morph · built-in",
|
|
2495
2955
|
command: "wt basic",
|
|
2496
2956
|
help: "switch this track to the morphing wavetable synth",
|
|
2497
2957
|
},
|
|
@@ -2501,7 +2961,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2501
2961
|
{
|
|
2502
2962
|
kind: "menu",
|
|
2503
2963
|
id: "strings",
|
|
2504
|
-
label: "
|
|
2964
|
+
label: "strings",
|
|
2505
2965
|
help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
|
|
2506
2966
|
detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
|
|
2507
2967
|
build: () => [
|
|
@@ -2516,7 +2976,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2516
2976
|
{
|
|
2517
2977
|
kind: "menu",
|
|
2518
2978
|
id: "strings:bowed",
|
|
2519
|
-
label: "
|
|
2979
|
+
label: "bowed",
|
|
2520
2980
|
help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
|
|
2521
2981
|
detail: BOWED_PRESET_NAMES.join(" "),
|
|
2522
2982
|
build: () =>
|
|
@@ -2532,7 +2992,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2532
2992
|
{
|
|
2533
2993
|
kind: "menu",
|
|
2534
2994
|
id: "granular-browse",
|
|
2535
|
-
label: "
|
|
2995
|
+
label: "granular",
|
|
2536
2996
|
help: "grain clouds from a built-in synth or this track's own sound",
|
|
2537
2997
|
detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
|
|
2538
2998
|
build: granularBrowseNodes,
|
|
@@ -2540,7 +3000,7 @@ function soundNodes(): MenuNode[] {
|
|
|
2540
3000
|
{
|
|
2541
3001
|
kind: "menu",
|
|
2542
3002
|
id: "instruments",
|
|
2543
|
-
label: "instruments",
|
|
3003
|
+
label: "all instruments",
|
|
2544
3004
|
help: "sampled piano and General MIDI instruments",
|
|
2545
3005
|
detail: "General MIDI soundfont, piano",
|
|
2546
3006
|
build: () => [
|
|
@@ -2558,9 +3018,9 @@ function soundNodes(): MenuNode[] {
|
|
|
2558
3018
|
},
|
|
2559
3019
|
{
|
|
2560
3020
|
kind: "entry",
|
|
2561
|
-
label: "use a
|
|
3021
|
+
label: "use a sample",
|
|
2562
3022
|
value: "",
|
|
2563
|
-
help: "type a pack
|
|
3023
|
+
help: "type a pack sample name, e.g. dirt-samples/bd:3",
|
|
2564
3024
|
placeholder: "<pack>/<sound>[:<n>]",
|
|
2565
3025
|
command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
|
|
2566
3026
|
example: "/pack use dirt-samples/bd:3",
|
|
@@ -2605,6 +3065,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2605
3065
|
label: "tempo",
|
|
2606
3066
|
help: "beats per minute",
|
|
2607
3067
|
value: score.tempoBpm,
|
|
3068
|
+
start: START_TEMPO_BPM,
|
|
2608
3069
|
min: SCORE_LIMITS.minTempoBpm,
|
|
2609
3070
|
max: SCORE_LIMITS.maxTempoBpm,
|
|
2610
3071
|
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
@@ -2616,6 +3077,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2616
3077
|
label: "beats per bar",
|
|
2617
3078
|
help: "the meter",
|
|
2618
3079
|
value: score.beatsPerBar,
|
|
3080
|
+
start: START_BEATS_PER_BAR,
|
|
2619
3081
|
min: 1,
|
|
2620
3082
|
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
2621
3083
|
step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
|
|
@@ -2628,20 +3090,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2628
3090
|
label: "loop length",
|
|
2629
3091
|
help: "how long the loop is",
|
|
2630
3092
|
value: score.bars,
|
|
3093
|
+
start: START_BARS,
|
|
2631
3094
|
min: 1,
|
|
2632
3095
|
max: SCORE_LIMITS.maxBars,
|
|
2633
3096
|
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
2634
3097
|
format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
|
|
2635
3098
|
command: (value) => `bars ${Math.round(value)}`,
|
|
2636
3099
|
},
|
|
2637
|
-
|
|
2638
|
-
kind: "choice",
|
|
2639
|
-
label: "grid",
|
|
2640
|
-
help: "the step play-mode recording and the euclid editor snap to",
|
|
2641
|
-
value: context.grid,
|
|
2642
|
-
options: context.grids,
|
|
2643
|
-
command: (option) => `/grid ${option}`,
|
|
2644
|
-
},
|
|
3100
|
+
gridNode(context),
|
|
2645
3101
|
{
|
|
2646
3102
|
kind: "toggle",
|
|
2647
3103
|
label: "click",
|
|
@@ -2658,12 +3114,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2658
3114
|
command: (option) => `/count-in ${option}`,
|
|
2659
3115
|
},
|
|
2660
3116
|
{
|
|
2661
|
-
kind: "
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
3117
|
+
kind: "choice",
|
|
3118
|
+
label: "calibration",
|
|
3119
|
+
help: "sound fixes: 1 chokes hats, tunes toms, levels keys, steadies brass · 0 keeps the legacy sound",
|
|
3120
|
+
value: String(score.calibration ?? 0),
|
|
3121
|
+
options: Array.from({ length: CALIBRATION_LATEST + 1 }, (_, i) =>
|
|
3122
|
+
String(i),
|
|
3123
|
+
),
|
|
3124
|
+
command: (option) => `/calibration ${option}`,
|
|
2667
3125
|
},
|
|
2668
3126
|
{
|
|
2669
3127
|
kind: "menu",
|
|
@@ -2673,6 +3131,115 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
2673
3131
|
help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
|
|
2674
3132
|
build: resampleNodes,
|
|
2675
3133
|
},
|
|
3134
|
+
{
|
|
3135
|
+
kind: "menu",
|
|
3136
|
+
id: "export",
|
|
3137
|
+
label: "export",
|
|
3138
|
+
detail: "project file · MIDI · WAV",
|
|
3139
|
+
help: "save the song as a project file or MIDI; audio renders offline",
|
|
3140
|
+
build: exportNodes,
|
|
3141
|
+
},
|
|
3142
|
+
{
|
|
3143
|
+
kind: "menu",
|
|
3144
|
+
id: "session",
|
|
3145
|
+
label: "session",
|
|
3146
|
+
detail: context.sessionName ?? "rename · fork · resume",
|
|
3147
|
+
help: "rename, fork or resume a session",
|
|
3148
|
+
build: sessionNodes,
|
|
3149
|
+
},
|
|
3150
|
+
{
|
|
3151
|
+
kind: "menu",
|
|
3152
|
+
id: "agent",
|
|
3153
|
+
label: "agent",
|
|
3154
|
+
detail: context.showMe
|
|
3155
|
+
? `show me ${context.showMe}`
|
|
3156
|
+
: "model · model key",
|
|
3157
|
+
help: "the agent's model, show-me and model key",
|
|
3158
|
+
build: agentNodes,
|
|
3159
|
+
},
|
|
3160
|
+
{
|
|
3161
|
+
kind: "menu",
|
|
3162
|
+
id: "audio",
|
|
3163
|
+
label: "audio",
|
|
3164
|
+
detail: context.audio
|
|
3165
|
+
? `out ${context.audio.output} · in ${context.audio.input}`
|
|
3166
|
+
: "output · input",
|
|
3167
|
+
help: "pick the audio output and input (one each, saved on this machine)",
|
|
3168
|
+
build: audioNodes,
|
|
3169
|
+
},
|
|
3170
|
+
{
|
|
3171
|
+
kind: "menu",
|
|
3172
|
+
id: "help",
|
|
3173
|
+
label: "help and guides",
|
|
3174
|
+
detail: "help · guides · keys",
|
|
3175
|
+
help: "the command reference, the guides and the keys",
|
|
3176
|
+
build: helpNodes,
|
|
3177
|
+
},
|
|
3178
|
+
];
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
/** Project › audio: two rows, each opening a device list. */
|
|
3182
|
+
function audioNodes(context: MenuContext): MenuNode[] {
|
|
3183
|
+
const audio = context.audio;
|
|
3184
|
+
if (!audio || audio.unavailable)
|
|
3185
|
+
return [
|
|
3186
|
+
{
|
|
3187
|
+
kind: "info",
|
|
3188
|
+
label: "devices",
|
|
3189
|
+
value: "system default",
|
|
3190
|
+
help: audio?.unavailable ?? "device choice needs the native sink",
|
|
3191
|
+
},
|
|
3192
|
+
{
|
|
3193
|
+
kind: "action",
|
|
3194
|
+
label: "show audio",
|
|
3195
|
+
command: "/audio",
|
|
3196
|
+
help: "the current output, input and why choice is unavailable",
|
|
3197
|
+
},
|
|
3198
|
+
];
|
|
3199
|
+
const list = (
|
|
3200
|
+
side: "out" | "in",
|
|
3201
|
+
names: readonly string[],
|
|
3202
|
+
current: string,
|
|
3203
|
+
): MenuNode[] => [
|
|
3204
|
+
{
|
|
3205
|
+
kind: "action",
|
|
3206
|
+
label: current.startsWith("default") ? "● default" : "default",
|
|
3207
|
+
command: `/audio ${side} default`,
|
|
3208
|
+
help: "the system default device (follows the system setting)",
|
|
3209
|
+
},
|
|
3210
|
+
...names.map((name): MenuNode => ({
|
|
3211
|
+
kind: "action",
|
|
3212
|
+
label: name === current ? `● ${name}` : name,
|
|
3213
|
+
command: `/audio ${side} ${name}`,
|
|
3214
|
+
help:
|
|
3215
|
+
side === "out"
|
|
3216
|
+
? `play through ${name} (a soft blip confirms it)`
|
|
3217
|
+
: `listen on ${name} (audio test meters it)`,
|
|
3218
|
+
})),
|
|
3219
|
+
];
|
|
3220
|
+
return [
|
|
3221
|
+
{
|
|
3222
|
+
kind: "menu",
|
|
3223
|
+
id: "output",
|
|
3224
|
+
label: "output",
|
|
3225
|
+
detail: audio.output,
|
|
3226
|
+
help: "where dawg plays: Enter picks, Esc goes back",
|
|
3227
|
+
build: () => list("out", audio.outputs, audio.output),
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
kind: "menu",
|
|
3231
|
+
id: "input",
|
|
3232
|
+
label: "input",
|
|
3233
|
+
detail: audio.input,
|
|
3234
|
+
help: "the input to meter now and record from later",
|
|
3235
|
+
build: () => list("in", audio.inputs, audio.input),
|
|
3236
|
+
},
|
|
3237
|
+
{
|
|
3238
|
+
kind: "action",
|
|
3239
|
+
label: "test",
|
|
3240
|
+
command: "/audio test",
|
|
3241
|
+
help: "a short tone on the output, then a level meter for the input",
|
|
3242
|
+
},
|
|
2676
3243
|
];
|
|
2677
3244
|
}
|
|
2678
3245
|
|
|
@@ -2720,9 +3287,9 @@ function resampleNodes(context: MenuContext): MenuNode[] {
|
|
|
2720
3287
|
},
|
|
2721
3288
|
{
|
|
2722
3289
|
kind: "action",
|
|
2723
|
-
label: "mix → sampler
|
|
3290
|
+
label: "mix → sampler",
|
|
2724
3291
|
command: "resample orbit 1",
|
|
2725
|
-
help: "render every track on orbit 1 (the default bus) without the song master",
|
|
3292
|
+
help: "pre-master: render every track on orbit 1 (the default bus) without the song master",
|
|
2726
3293
|
},
|
|
2727
3294
|
{
|
|
2728
3295
|
kind: "action",
|
|
@@ -2899,7 +3466,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2899
3466
|
help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
|
|
2900
3467
|
value: scale,
|
|
2901
3468
|
options: [...MODE_NAMES, ...SCALE_NAMES],
|
|
2902
|
-
command: (option) => `
|
|
3469
|
+
command: (option) => `key ${option}`,
|
|
2903
3470
|
},
|
|
2904
3471
|
{
|
|
2905
3472
|
kind: "choice",
|
|
@@ -2907,7 +3474,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2907
3474
|
help: "the song key's tonic; keeps the scale",
|
|
2908
3475
|
value: tonic,
|
|
2909
3476
|
options: TONICS,
|
|
2910
|
-
command: (option) => `
|
|
3477
|
+
command: (option) => `key ${option} ${scale}`,
|
|
2911
3478
|
},
|
|
2912
3479
|
{
|
|
2913
3480
|
kind: "action",
|
|
@@ -2918,7 +3485,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
|
|
|
2918
3485
|
{
|
|
2919
3486
|
kind: "action",
|
|
2920
3487
|
label: "list scales",
|
|
2921
|
-
command: "
|
|
3488
|
+
command: "key list",
|
|
2922
3489
|
help: "every mode and scale, by family",
|
|
2923
3490
|
},
|
|
2924
3491
|
];
|
|
@@ -2960,14 +3527,7 @@ type Frame = {
|
|
|
2960
3527
|
hovering?: boolean;
|
|
2961
3528
|
};
|
|
2962
3529
|
|
|
2963
|
-
const
|
|
2964
|
-
const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
|
|
2965
|
-
const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
2966
|
-
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
2967
|
-
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
2968
|
-
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
2969
|
-
const KEY_DELETE = new Set(["\u001b[3~", "x"]);
|
|
2970
|
-
const LABEL_WIDTH = 16;
|
|
3530
|
+
export const LABEL_WIDTH = 16;
|
|
2971
3531
|
|
|
2972
3532
|
/**
|
|
2973
3533
|
* A submenu frame whose rows are rebuilt from the parent's fresh build, so
|
|
@@ -2998,6 +3558,11 @@ function childFrame(
|
|
|
2998
3558
|
export class EditMenu {
|
|
2999
3559
|
private stack: Frame[] = [];
|
|
3000
3560
|
private filtering = false;
|
|
3561
|
+
/**
|
|
3562
|
+
* The fader drawer's front page (design §8.6): the open level's knob page
|
|
3563
|
+
* and whether Tab paged to every param. Undefined: plain rows.
|
|
3564
|
+
*/
|
|
3565
|
+
private knobView: { page: string; all: boolean } | undefined;
|
|
3001
3566
|
/** Typed value for the selected row (`entry` holds the row's label). */
|
|
3002
3567
|
private entry: { label: string; buffer: string } | undefined;
|
|
3003
3568
|
|
|
@@ -3020,10 +3585,8 @@ export class EditMenu {
|
|
|
3020
3585
|
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
3021
3586
|
this.filtering = false;
|
|
3022
3587
|
this.entry = undefined;
|
|
3023
|
-
// Walk the section path (`automation` is Mix
|
|
3024
|
-
for (const id of section
|
|
3025
|
-
? (SECTION_ALIASES[section.toLowerCase()] ?? [])
|
|
3026
|
-
: []) {
|
|
3588
|
+
// Walk the section path (`automation` is Mix › automation).
|
|
3589
|
+
for (const id of section ? (menuSectionPath(context, section) ?? []) : []) {
|
|
3027
3590
|
const frame = this.stack.at(-1)!;
|
|
3028
3591
|
const nodes = frame.build(context);
|
|
3029
3592
|
const index = nodes.findIndex(
|
|
@@ -3036,6 +3599,21 @@ export class EditMenu {
|
|
|
3036
3599
|
}
|
|
3037
3600
|
}
|
|
3038
3601
|
|
|
3602
|
+
/** Step into the open level's submenu `id`; false when it has none. */
|
|
3603
|
+
enter(context: MenuContext, id: string): boolean {
|
|
3604
|
+
const frame = this.stack.at(-1);
|
|
3605
|
+
if (!frame) return false;
|
|
3606
|
+
const nodes = frame.build(context);
|
|
3607
|
+
const index = nodes.findIndex(
|
|
3608
|
+
(node) => node.kind === "menu" && node.id === id,
|
|
3609
|
+
);
|
|
3610
|
+
const node = nodes[index];
|
|
3611
|
+
if (node?.kind !== "menu") return false;
|
|
3612
|
+
frame.index = index;
|
|
3613
|
+
this.stack.push(childFrame(frame, node));
|
|
3614
|
+
return true;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3039
3617
|
/**
|
|
3040
3618
|
* Open on the level a bare parameter command edits, for the fader drawer:
|
|
3041
3619
|
* `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
|
|
@@ -3122,6 +3700,77 @@ export class EditMenu {
|
|
|
3122
3700
|
this.stack = [];
|
|
3123
3701
|
this.filtering = false;
|
|
3124
3702
|
this.entry = undefined;
|
|
3703
|
+
this.knobView = undefined;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/**
|
|
3707
|
+
* The knob page of the open level (design §7.3), or undefined where the
|
|
3708
|
+
* level has no knobs: Sound (by engine), Mix and its mixer (this track),
|
|
3709
|
+
* the master, Project (tempo) and one effect.
|
|
3710
|
+
*/
|
|
3711
|
+
knobPageHere(context: MenuContext): string | undefined {
|
|
3712
|
+
const ids = this.stack.slice(1).map((frame) => frame.id ?? "");
|
|
3713
|
+
const [top, sub] = ids;
|
|
3714
|
+
const track = focused(context);
|
|
3715
|
+
if (top === "sound" && ids.length === 1)
|
|
3716
|
+
return track ? `sound:${soundFamily(track)}` : undefined;
|
|
3717
|
+
if (
|
|
3718
|
+
top === "mix" &&
|
|
3719
|
+
(ids.length === 1 || (ids.length === 2 && sub === "mixer"))
|
|
3720
|
+
)
|
|
3721
|
+
return "mix";
|
|
3722
|
+
if (top === "mix" && ids.length === 2 && sub === "master") return "master";
|
|
3723
|
+
if (top === "project" && ids.length === 1) return "tempo";
|
|
3724
|
+
const last = ids.at(-1) ?? "";
|
|
3725
|
+
if (top === "effects" && (EFFECT_NAMES as readonly string[]).includes(last))
|
|
3726
|
+
return `fx:${last}`;
|
|
3727
|
+
return undefined;
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
/**
|
|
3731
|
+
* Turn the drawer's knob front page on for the open level. `label` is the
|
|
3732
|
+
* row the drawer opens on: when it is not one of the knobs (or the level
|
|
3733
|
+
* is the mixer), the drawer opens on every param instead. Returns the
|
|
3734
|
+
* label to focus, or undefined when the level has no knob page.
|
|
3735
|
+
*/
|
|
3736
|
+
openKnobs(context: MenuContext, label?: string): string | undefined {
|
|
3737
|
+
const page = this.knobPageHere(context);
|
|
3738
|
+
this.knobView = undefined;
|
|
3739
|
+
if (!page) return undefined;
|
|
3740
|
+
const frame = this.stack.at(-1)!;
|
|
3741
|
+
const fallback = faderSpecs(frame.build(context));
|
|
3742
|
+
const knobs = knobFields(context, page, fallback);
|
|
3743
|
+
if (knobs.length === 0) return undefined;
|
|
3744
|
+
const mixer = frame.id === "mixer";
|
|
3745
|
+
const onKnob =
|
|
3746
|
+
label === undefined || knobs.some((knob) => knob.label === label);
|
|
3747
|
+
this.knobView = { page, all: mixer || !onKnob };
|
|
3748
|
+
if (this.knobView.all) return label ?? fallback[0]?.label;
|
|
3749
|
+
return label ?? knobs[0]!.label;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
/** Which drawer page is up: the four knobs, every param, or no knobs. */
|
|
3753
|
+
get knobPage(): "knobs" | "all" | undefined {
|
|
3754
|
+
return this.knobView ? (this.knobView.all ? "all" : "knobs") : undefined;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
/** The knob page id behind the drawer (`sound:synth`, `mix`), if any. */
|
|
3758
|
+
get knobPageId(): string | undefined {
|
|
3759
|
+
return this.knobView?.page;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
/**
|
|
3763
|
+
* Tab in the drawer: flip between the four knobs and every param (on the
|
|
3764
|
+
* mixer, between all tracks and this track). Returns the label to focus:
|
|
3765
|
+
* `keep` when it is on the new page, else the page's first field.
|
|
3766
|
+
*/
|
|
3767
|
+
pageKnobs(context: MenuContext, keep: string): string | undefined {
|
|
3768
|
+
if (!this.knobView) return undefined;
|
|
3769
|
+
this.knobView = { ...this.knobView, all: !this.knobView.all };
|
|
3770
|
+
const fields = this.faderFields(context);
|
|
3771
|
+
return fields.some((field) => field.label === keep)
|
|
3772
|
+
? keep
|
|
3773
|
+
: fields[0]?.label;
|
|
3125
3774
|
}
|
|
3126
3775
|
|
|
3127
3776
|
/** The visible rows of the current level (filtered). */
|
|
@@ -3130,9 +3779,15 @@ export class EditMenu {
|
|
|
3130
3779
|
if (!frame) return [];
|
|
3131
3780
|
const all = frame.build(context);
|
|
3132
3781
|
const needle = frame.query.toLowerCase();
|
|
3133
|
-
return
|
|
3134
|
-
|
|
3135
|
-
|
|
3782
|
+
if (!needle) return all;
|
|
3783
|
+
const direct = all.filter((node) =>
|
|
3784
|
+
nodeText(node).toLowerCase().includes(needle),
|
|
3785
|
+
);
|
|
3786
|
+
// At the root a filter also looks two levels down for groups
|
|
3787
|
+
// (`/autotune`, `/formant`, `/tuning`) and offers them by path, after
|
|
3788
|
+
// the roots that match.
|
|
3789
|
+
if (this.stack.length !== 1) return direct;
|
|
3790
|
+
return [...direct, ...deepGroups(all, context, needle)];
|
|
3136
3791
|
}
|
|
3137
3792
|
|
|
3138
3793
|
private selected(context: MenuContext): MenuNode | undefined {
|
|
@@ -3160,11 +3815,7 @@ export class EditMenu {
|
|
|
3160
3815
|
frame.index = 0;
|
|
3161
3816
|
return { type: "handled" };
|
|
3162
3817
|
}
|
|
3163
|
-
if (
|
|
3164
|
-
KEY_ENTER.has(value) ||
|
|
3165
|
-
value === "\u001b[B" ||
|
|
3166
|
-
value === "\u001b[A"
|
|
3167
|
-
) {
|
|
3818
|
+
if (KEY_ENTER.has(value) || value === ARROW_DOWN || value === ARROW_UP) {
|
|
3168
3819
|
this.filtering = false;
|
|
3169
3820
|
return this.key(value, context);
|
|
3170
3821
|
}
|
|
@@ -3203,11 +3854,11 @@ export class EditMenu {
|
|
|
3203
3854
|
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
3204
3855
|
return this.hovered(frame, context);
|
|
3205
3856
|
}
|
|
3206
|
-
if (value
|
|
3857
|
+
if (KEY_PAGE_UP.has(value) || KEY_HOME.has(value)) {
|
|
3207
3858
|
frame.index = 0;
|
|
3208
3859
|
return this.hovered(frame, context);
|
|
3209
3860
|
}
|
|
3210
|
-
if (value
|
|
3861
|
+
if (KEY_PAGE_DOWN.has(value) || KEY_END.has(value)) {
|
|
3211
3862
|
frame.index = Math.max(0, nodes.length - 1);
|
|
3212
3863
|
return this.hovered(frame, context);
|
|
3213
3864
|
}
|
|
@@ -3226,11 +3877,19 @@ export class EditMenu {
|
|
|
3226
3877
|
node.kind === "info")
|
|
3227
3878
|
)
|
|
3228
3879
|
return { type: "keep" };
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3880
|
+
// ← → adjust a value; on any other row ← h go back and → l go in
|
|
3881
|
+
// (or run the action), and - + do nothing.
|
|
3882
|
+
const isValue = isValueNode(node);
|
|
3883
|
+
if (isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
|
|
3884
|
+
return this.nudge(node, KEY_RIGHT.has(value) ? 1 : -1);
|
|
3885
|
+
if (!isValue && KEY_BACK.has(value)) return this.back();
|
|
3886
|
+
if (!isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
|
|
3887
|
+
if (!(
|
|
3888
|
+
KEY_FORWARD.has(value) &&
|
|
3889
|
+
(node.kind === "menu" || node.kind === "action")
|
|
3890
|
+
))
|
|
3891
|
+
return { type: "handled" };
|
|
3892
|
+
if (KEY_RESET.has(value) && node.kind === "number") {
|
|
3234
3893
|
const reset =
|
|
3235
3894
|
node.reset ??
|
|
3236
3895
|
(node.start !== undefined && node.value !== undefined
|
|
@@ -3238,7 +3897,7 @@ export class EditMenu {
|
|
|
3238
3897
|
: undefined);
|
|
3239
3898
|
return reset ? { type: "run", command: reset } : { type: "handled" };
|
|
3240
3899
|
}
|
|
3241
|
-
if (
|
|
3900
|
+
if (KEY_RESET.has(value) && node.kind === "point")
|
|
3242
3901
|
return {
|
|
3243
3902
|
type: "run",
|
|
3244
3903
|
command: `automate ${node.lane} remove ${num(node.beat)}`,
|
|
@@ -3250,7 +3909,7 @@ export class EditMenu {
|
|
|
3250
3909
|
this.entry = { label: node.label, buffer: value };
|
|
3251
3910
|
return { type: "handled" };
|
|
3252
3911
|
}
|
|
3253
|
-
if (KEY_ENTER.has(value) || value === " ") {
|
|
3912
|
+
if (KEY_ENTER.has(value) || KEY_FORWARD.has(value) || value === " ") {
|
|
3254
3913
|
if (node.kind === "menu") {
|
|
3255
3914
|
// Going back lands on the opened row with the filter cleared.
|
|
3256
3915
|
if (frame.query) {
|
|
@@ -3326,15 +3985,16 @@ export class EditMenu {
|
|
|
3326
3985
|
return { type: "hover", command: node.command, key: frame.hover };
|
|
3327
3986
|
}
|
|
3328
3987
|
|
|
3988
|
+
/** ← on a row that is not a value: up one level (never past the root). */
|
|
3989
|
+
private back(): MenuResult {
|
|
3990
|
+
if (this.stack.length <= 1) return { type: "handled" };
|
|
3991
|
+
const left = this.stack.pop();
|
|
3992
|
+
if (left?.hover && left.hovering)
|
|
3993
|
+
return { type: "unhover", key: left.hover };
|
|
3994
|
+
return { type: "handled" };
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3329
3997
|
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
3330
|
-
if (node.kind === "menu") {
|
|
3331
|
-
if (direction > 0) {
|
|
3332
|
-
this.stack.push(childFrame(this.stack.at(-1)!, node));
|
|
3333
|
-
return { type: "handled" };
|
|
3334
|
-
}
|
|
3335
|
-
if (this.stack.length > 1) this.stack.pop();
|
|
3336
|
-
return { type: "handled" };
|
|
3337
|
-
}
|
|
3338
3998
|
if (node.kind === "number") {
|
|
3339
3999
|
const next =
|
|
3340
4000
|
node.value === undefined
|
|
@@ -3367,11 +4027,6 @@ export class EditMenu {
|
|
|
3367
4027
|
? { type: "handled" }
|
|
3368
4028
|
: { type: "run", command: node.command(next) };
|
|
3369
4029
|
}
|
|
3370
|
-
if (direction < 0 && this.stack.length > 1) {
|
|
3371
|
-
const left = this.stack.pop();
|
|
3372
|
-
if (left?.hover && left.hovering)
|
|
3373
|
-
return { type: "unhover", key: left.hover };
|
|
3374
|
-
}
|
|
3375
4030
|
return { type: "handled" };
|
|
3376
4031
|
}
|
|
3377
4032
|
|
|
@@ -3413,10 +4068,26 @@ export class EditMenu {
|
|
|
3413
4068
|
* related params: every filter control, every reverb control), built
|
|
3414
4069
|
* from `context` (the staged score while auditioning).
|
|
3415
4070
|
*/
|
|
3416
|
-
faderFields(context: MenuContext): FaderSpec[] {
|
|
4071
|
+
faderFields(context: MenuContext): (FaderSpec | KnobField)[] {
|
|
3417
4072
|
const frame = this.stack.at(-1);
|
|
3418
4073
|
if (!frame) return [];
|
|
3419
|
-
|
|
4074
|
+
const rows = faderSpecs(frame.build(context));
|
|
4075
|
+
if (!this.knobView) return rows;
|
|
4076
|
+
if (!this.knobView.all)
|
|
4077
|
+
return knobFields(context, this.knobView.page, rows);
|
|
4078
|
+
// The mixer's rows are all levels: each wears the orange knob.
|
|
4079
|
+
if (frame.id === "mixer")
|
|
4080
|
+
return rows.map((row) => ({ ...row, knob: 3 as const }));
|
|
4081
|
+
return rows;
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
/**
|
|
4085
|
+
* Every param of the current level, whichever drawer page is up: the
|
|
4086
|
+
* side list a wide drawer shows beside the four knobs.
|
|
4087
|
+
*/
|
|
4088
|
+
pageFields(context: MenuContext): FaderSpec[] {
|
|
4089
|
+
const frame = this.stack.at(-1);
|
|
4090
|
+
return frame ? faderSpecs(frame.build(context)) : [];
|
|
3420
4091
|
}
|
|
3421
4092
|
|
|
3422
4093
|
/** The same fields as committed, for the drawer's `staged ← committed`. */
|
|
@@ -3502,6 +4173,16 @@ export class EditMenu {
|
|
|
3502
4173
|
}
|
|
3503
4174
|
}
|
|
3504
4175
|
|
|
4176
|
+
/** Rows that hold a value: ← → adjust them instead of navigating. */
|
|
4177
|
+
function isValueNode(node: MenuNode): boolean {
|
|
4178
|
+
return (
|
|
4179
|
+
node.kind === "number" ||
|
|
4180
|
+
node.kind === "point" ||
|
|
4181
|
+
node.kind === "toggle" ||
|
|
4182
|
+
node.kind === "choice"
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3505
4186
|
/** The focused row's one-line description. */
|
|
3506
4187
|
function describe(node: MenuNode): string {
|
|
3507
4188
|
if (node.help) return node.help;
|
|
@@ -3663,28 +4344,155 @@ function commandText(node: MenuNode): string | undefined {
|
|
|
3663
4344
|
}
|
|
3664
4345
|
}
|
|
3665
4346
|
|
|
4347
|
+
/** Menu groups below `nodes` (two levels) whose label matches `needle`. */
|
|
4348
|
+
function deepGroups(
|
|
4349
|
+
nodes: readonly MenuNode[],
|
|
4350
|
+
context: MenuContext,
|
|
4351
|
+
needle: string,
|
|
4352
|
+
): MenuNode[] {
|
|
4353
|
+
const found: MenuNode[] = [];
|
|
4354
|
+
const visit = (list: readonly MenuNode[], path: string, depth: number) => {
|
|
4355
|
+
for (const node of list) {
|
|
4356
|
+
if (node.kind !== "menu" || found.length >= 12) continue;
|
|
4357
|
+
const label = path ? `${path} › ${node.label}` : node.label;
|
|
4358
|
+
if (path && node.label.toLowerCase().includes(needle))
|
|
4359
|
+
found.push({ ...node, label });
|
|
4360
|
+
if (depth < 2) {
|
|
4361
|
+
let children: MenuNode[] = [];
|
|
4362
|
+
try {
|
|
4363
|
+
children = node.build(context);
|
|
4364
|
+
} catch {
|
|
4365
|
+
continue;
|
|
4366
|
+
}
|
|
4367
|
+
visit(children, label, depth + 1);
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
};
|
|
4371
|
+
visit(nodes, "", 0);
|
|
4372
|
+
return found;
|
|
4373
|
+
}
|
|
4374
|
+
|
|
3666
4375
|
function nodeText(node: MenuNode): string {
|
|
3667
4376
|
return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
|
|
3668
4377
|
}
|
|
3669
4378
|
|
|
3670
|
-
/**
|
|
3671
|
-
export const MENU_SECTIONS =
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
4379
|
+
/** Every id `/menu <id>` opens: the ten topics first, then older ids. */
|
|
4380
|
+
export const MENU_SECTIONS: readonly string[] = Object.freeze(
|
|
4381
|
+
Object.keys(SECTION_ALIASES),
|
|
4382
|
+
);
|
|
4383
|
+
|
|
4384
|
+
/** The ten topic ids, the roots `/menu` names first. */
|
|
4385
|
+
export const MENU_TOPICS: readonly string[] = Object.freeze(
|
|
4386
|
+
MENU_SECTIONS.slice(0, 10),
|
|
4387
|
+
);
|
|
4388
|
+
|
|
4389
|
+
/** The names `/menu` and `/help menu` list: one per topic, no aliases. */
|
|
4390
|
+
export const MENU_SHOWN_SECTIONS: readonly string[] = MENU_TOPICS;
|
|
4391
|
+
|
|
4392
|
+
/** `/menu`'s usage line: the short form; `/help menu` lists every topic. */
|
|
4393
|
+
export const MENU_USAGE = "usage: /menu <topic or row> · /help menu lists them";
|
|
4394
|
+
|
|
4395
|
+
/**
|
|
4396
|
+
* The error for `/menu <section>` when nothing matches: the nearest topic
|
|
4397
|
+
* or row name first, so it fits 80 columns, then the short usage.
|
|
4398
|
+
*/
|
|
4399
|
+
export function menuUsage(context: MenuContext, section: string): string {
|
|
4400
|
+
const names = new Set<string>(MENU_SECTIONS);
|
|
4401
|
+
for (const node of rootNodes(context))
|
|
4402
|
+
if (node.kind === "menu") {
|
|
4403
|
+
let children: MenuNode[] = [];
|
|
4404
|
+
try {
|
|
4405
|
+
children = node.build(context);
|
|
4406
|
+
} catch {
|
|
4407
|
+
continue;
|
|
4408
|
+
}
|
|
4409
|
+
for (const child of children)
|
|
4410
|
+
if (child.kind === "menu") names.add(labelName(child.label));
|
|
4411
|
+
}
|
|
4412
|
+
const near = nearest(section, names);
|
|
4413
|
+
return `no menu "${section}"${near ? ` · did you mean /menu ${near}?` : ""} · /menu <topic or row>`;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
/** A row label without its padded detail: `granular 12 presets` → `granular`. */
|
|
4417
|
+
function labelName(label: string): string {
|
|
4418
|
+
return label
|
|
4419
|
+
.split(/\s{2,}/)[0]!
|
|
4420
|
+
.trim()
|
|
4421
|
+
.toLowerCase();
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/**
|
|
4425
|
+
* The path of menu ids `/menu <section>` walks: a topic or older id from
|
|
4426
|
+
* SECTION_ALIASES, else the first menu row (three levels deep, breadth
|
|
4427
|
+
* first) whose id or label is `section`. Undefined when nothing matches.
|
|
4428
|
+
*/
|
|
4429
|
+
export function menuSectionPath(
|
|
4430
|
+
context: MenuContext,
|
|
4431
|
+
section: string,
|
|
4432
|
+
): readonly string[] | undefined {
|
|
4433
|
+
const needle = section.trim().toLowerCase();
|
|
4434
|
+
if (!needle) return undefined;
|
|
4435
|
+
const alias = SECTION_ALIASES[needle];
|
|
4436
|
+
if (alias) return alias;
|
|
4437
|
+
type Step = { nodes: readonly MenuNode[]; path: readonly string[] };
|
|
4438
|
+
let level: Step[] = [{ nodes: rootNodes(context), path: [] }];
|
|
4439
|
+
for (let depth = 0; depth < 3 && level.length; depth += 1) {
|
|
4440
|
+
const next: Step[] = [];
|
|
4441
|
+
for (const { nodes, path } of level) {
|
|
4442
|
+
for (const node of nodes) {
|
|
4443
|
+
if (node.kind !== "menu") continue;
|
|
4444
|
+
const at = [...path, node.id];
|
|
4445
|
+
if (node.id === needle || node.label.toLowerCase() === needle)
|
|
4446
|
+
return at;
|
|
4447
|
+
if (depth < 2) {
|
|
4448
|
+
try {
|
|
4449
|
+
next.push({ nodes: node.build(context), path: at });
|
|
4450
|
+
} catch {
|
|
4451
|
+
// A section that cannot build here has no rows to match.
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
level = next;
|
|
4457
|
+
}
|
|
4458
|
+
return undefined;
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
/** A one-track project, for breadcrumbs asked for without a window. */
|
|
4462
|
+
function breadcrumbContext(): MenuContext {
|
|
4463
|
+
return {
|
|
4464
|
+
score: createScore({
|
|
4465
|
+
tempoBpm: 120,
|
|
4466
|
+
bars: 8,
|
|
4467
|
+
tracks: [{ id: "keys", name: "keys", instrument: "piano" }],
|
|
4468
|
+
notes: [],
|
|
4469
|
+
}),
|
|
4470
|
+
trackId: "keys",
|
|
4471
|
+
playing: false,
|
|
4472
|
+
grid: "1/16",
|
|
4473
|
+
grids: ["1/16"],
|
|
4474
|
+
clickOn: false,
|
|
4475
|
+
countInBars: 1,
|
|
4476
|
+
};
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
/**
|
|
4480
|
+
* `Ctrl-K › Chords and key › tuning`: where `/menu <id>` opens, from the
|
|
4481
|
+
* live labels. Undefined when `id` opens nothing.
|
|
4482
|
+
*/
|
|
4483
|
+
export function menuPath(
|
|
4484
|
+
id: string,
|
|
4485
|
+
context: MenuContext = breadcrumbContext(),
|
|
4486
|
+
): string | undefined {
|
|
4487
|
+
const path = menuSectionPath(context, id);
|
|
4488
|
+
if (!path) return undefined;
|
|
4489
|
+
const labels: string[] = [];
|
|
4490
|
+
let nodes: readonly MenuNode[] = rootNodes(context);
|
|
4491
|
+
for (const step of path) {
|
|
4492
|
+
const node = nodes.find((row) => row.kind === "menu" && row.id === step);
|
|
4493
|
+
if (node?.kind !== "menu") return undefined;
|
|
4494
|
+
labels.push(node.label);
|
|
4495
|
+
nodes = node.build(context);
|
|
4496
|
+
}
|
|
4497
|
+
return ["Ctrl-K", ...labels].join(" › ");
|
|
4498
|
+
}
|