@hraness/dawg 0.2.0 → 0.4.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 +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/tui/menu.ts
ADDED
|
@@ -0,0 +1,2125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-editing menu (`/menu`, Ctrl-K): every edit the agent can make,
|
|
3
|
+
* reachable with arrow keys. Pure: the tree is rebuilt from the score on
|
|
4
|
+
* every key, and a change is returned as the command it stands for, which
|
|
5
|
+
* the caller runs through the normal prompt path (one ScoreOperation, one
|
|
6
|
+
* receipt, one undo step). Each row shows its current value and that
|
|
7
|
+
* command, so the menu teaches the commands.
|
|
8
|
+
*
|
|
9
|
+
* Rendering reuses the TUI's picker overlay: `view()` returns a picker.
|
|
10
|
+
*/
|
|
11
|
+
import { commandParam, sketchFor } from "./sketch.ts";
|
|
12
|
+
import { HINTS } from "../../tui/grammar.ts";
|
|
13
|
+
import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
|
|
14
|
+
import {
|
|
15
|
+
AUTOMATION_PARAMETERS,
|
|
16
|
+
automationPoints,
|
|
17
|
+
automationRange,
|
|
18
|
+
REVERB_IR_BUILTINS,
|
|
19
|
+
SAMPLE_UNITS,
|
|
20
|
+
SCORE_LIMITS,
|
|
21
|
+
isTrackAutomationParameter,
|
|
22
|
+
WARP_MODES,
|
|
23
|
+
WAVETABLE_PARAMS,
|
|
24
|
+
isWavetableInstrument,
|
|
25
|
+
wavetableOf,
|
|
26
|
+
type WavetableParam,
|
|
27
|
+
type AutomationParameter,
|
|
28
|
+
type Track,
|
|
29
|
+
type TrackAutomationParameter,
|
|
30
|
+
type TrackScore,
|
|
31
|
+
} from "../../core/score.ts";
|
|
32
|
+
import {
|
|
33
|
+
CORE_EFFECTS,
|
|
34
|
+
EFFECT_NAMES,
|
|
35
|
+
FX_LANES,
|
|
36
|
+
effectPresetNames,
|
|
37
|
+
effectSpec,
|
|
38
|
+
type EffectName,
|
|
39
|
+
type FxLane,
|
|
40
|
+
type NumberParam,
|
|
41
|
+
type ParamSpec,
|
|
42
|
+
} from "../../core/fx.ts";
|
|
43
|
+
import { effectValues } from "../commands/fx.ts";
|
|
44
|
+
import { SAMPLE_CONTROLS, type SampleControl } from "../commands/sample.ts";
|
|
45
|
+
import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
46
|
+
import {
|
|
47
|
+
DEFAULT_KITS,
|
|
48
|
+
GM_INSTRUMENTS,
|
|
49
|
+
PACK_CATALOG,
|
|
50
|
+
STRUDEL_BANK_ALIASES,
|
|
51
|
+
} from "../audio/packs.ts";
|
|
52
|
+
import { kitCatalog } from "../audio/kits.ts";
|
|
53
|
+
import { SYNTH_KIT_NAMES } from "../../core/kits.ts";
|
|
54
|
+
import { DRUM_PATTERNS } from "../../core/sdk/v1.ts";
|
|
55
|
+
import { isDrumInstrument } from "../../core/drums.ts";
|
|
56
|
+
import {
|
|
57
|
+
SYNTH_GROUPS,
|
|
58
|
+
SYNTH_PARAMS,
|
|
59
|
+
SYNTH_PRESETS,
|
|
60
|
+
SYNTH_SIMPLE,
|
|
61
|
+
normalizeSynth,
|
|
62
|
+
} from "../../core/synth.ts";
|
|
63
|
+
import type { PickerItem } from "../../tui/app.ts";
|
|
64
|
+
import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
|
|
65
|
+
import {
|
|
66
|
+
UZU_WAVETABLES,
|
|
67
|
+
WAVETABLE_PACK,
|
|
68
|
+
describeTable,
|
|
69
|
+
listLocalWavetables,
|
|
70
|
+
} from "../commands/wavetable.ts";
|
|
71
|
+
import {
|
|
72
|
+
BASS_MODES,
|
|
73
|
+
CHORD_PATTERNS,
|
|
74
|
+
MAX_VOICING_STEP,
|
|
75
|
+
MODE_NAMES,
|
|
76
|
+
PERFORM_MODES,
|
|
77
|
+
PROGRESSION_PRESETS,
|
|
78
|
+
PROGRESSION_STYLES,
|
|
79
|
+
SPREADS,
|
|
80
|
+
keyName,
|
|
81
|
+
parseKey,
|
|
82
|
+
} from "../../core/chords.ts";
|
|
83
|
+
import {
|
|
84
|
+
ARP_RATES,
|
|
85
|
+
CHORD_MODES,
|
|
86
|
+
defaultChordSettings,
|
|
87
|
+
type ChordSettings,
|
|
88
|
+
} from "./play-chords.ts";
|
|
89
|
+
|
|
90
|
+
/** What the menu needs to know beyond the score. */
|
|
91
|
+
export type MenuContext = Readonly<{
|
|
92
|
+
score: TrackScore;
|
|
93
|
+
trackId: string;
|
|
94
|
+
playing: boolean;
|
|
95
|
+
grid: string;
|
|
96
|
+
grids: readonly string[];
|
|
97
|
+
clickOn: boolean;
|
|
98
|
+
countInBars: number;
|
|
99
|
+
/** Play mode's chord settings (defaults when absent). */
|
|
100
|
+
chords?: ChordSettings;
|
|
101
|
+
/** Project root, for the project's own wavetables (none when absent). */
|
|
102
|
+
projectRoot?: string;
|
|
103
|
+
/**
|
|
104
|
+
* The audition loop (src/tui/audition.ts), when the window hosts one.
|
|
105
|
+
* `score` above is then the staged score; changed rows show the
|
|
106
|
+
* committed value beside the staged one.
|
|
107
|
+
*/
|
|
108
|
+
audition?: MenuAudition;
|
|
109
|
+
}>;
|
|
110
|
+
|
|
111
|
+
/** What the menu needs from the audition controller. */
|
|
112
|
+
export type MenuAudition = Readonly<{
|
|
113
|
+
looping: boolean;
|
|
114
|
+
/** Edits are staged (an Enter keeps them, Esc reverts them). */
|
|
115
|
+
dirty: boolean;
|
|
116
|
+
/** The committed score, for `staged ← committed` on changed rows. */
|
|
117
|
+
committed: TrackScore;
|
|
118
|
+
/** The footer while auditioning. */
|
|
119
|
+
hint: string;
|
|
120
|
+
/** `♪ solo · B staged 2 · 42 ms`, shown in the title. */
|
|
121
|
+
status?: string | undefined;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
type NumberField = Readonly<{
|
|
125
|
+
kind: "number";
|
|
126
|
+
label: string;
|
|
127
|
+
/** `undefined` while the effect is off; a nudge then turns it on. */
|
|
128
|
+
value: number | undefined;
|
|
129
|
+
min: number;
|
|
130
|
+
max: number;
|
|
131
|
+
/** Next value one step in `direction`. */
|
|
132
|
+
step: (value: number, direction: 1 | -1) => number;
|
|
133
|
+
format: (value: number) => string;
|
|
134
|
+
command: (value: number) => string;
|
|
135
|
+
/** Shown while `value` is undefined. */
|
|
136
|
+
off?: string;
|
|
137
|
+
/** Starting value for a nudge while off. */
|
|
138
|
+
start?: number;
|
|
139
|
+
/** The command `x` runs to put the value back to its default. */
|
|
140
|
+
reset?: string;
|
|
141
|
+
}>;
|
|
142
|
+
|
|
143
|
+
export type MenuNode = (
|
|
144
|
+
| Readonly<{
|
|
145
|
+
kind: "menu";
|
|
146
|
+
id: string;
|
|
147
|
+
label: string;
|
|
148
|
+
detail: string;
|
|
149
|
+
build: (context: MenuContext) => MenuNode[];
|
|
150
|
+
}>
|
|
151
|
+
| NumberField
|
|
152
|
+
| Readonly<{
|
|
153
|
+
kind: "toggle";
|
|
154
|
+
label: string;
|
|
155
|
+
value: boolean;
|
|
156
|
+
command: (value: boolean) => string;
|
|
157
|
+
}>
|
|
158
|
+
| Readonly<{
|
|
159
|
+
kind: "choice";
|
|
160
|
+
label: string;
|
|
161
|
+
value: string;
|
|
162
|
+
options: readonly string[];
|
|
163
|
+
command: (option: string) => string;
|
|
164
|
+
}>
|
|
165
|
+
| Readonly<{
|
|
166
|
+
kind: "entry";
|
|
167
|
+
label: string;
|
|
168
|
+
value: string;
|
|
169
|
+
/** Shown in the title while typing. */
|
|
170
|
+
placeholder: string;
|
|
171
|
+
/** Command for the typed text; undefined when it cannot parse. */
|
|
172
|
+
command: (text: string) => string | undefined;
|
|
173
|
+
/** Shown as the slash-command equivalent. */
|
|
174
|
+
example: string;
|
|
175
|
+
}>
|
|
176
|
+
| Readonly<{ kind: "action"; label: string; command: string }>
|
|
177
|
+
| Readonly<{
|
|
178
|
+
kind: "point";
|
|
179
|
+
label: string;
|
|
180
|
+
lane: AutomationParameter;
|
|
181
|
+
beat: number;
|
|
182
|
+
value: number;
|
|
183
|
+
}>
|
|
184
|
+
| Readonly<{ kind: "info"; label: string; value: string }>
|
|
185
|
+
) &
|
|
186
|
+
Readonly<{
|
|
187
|
+
/** One line on what the focused row does, shown under the list. */
|
|
188
|
+
help?: string;
|
|
189
|
+
}>;
|
|
190
|
+
|
|
191
|
+
/** What a key did; `run` carries the command to execute. */
|
|
192
|
+
export type MenuResult =
|
|
193
|
+
| { type: "handled" }
|
|
194
|
+
| { type: "close" }
|
|
195
|
+
| { type: "run"; command: string }
|
|
196
|
+
/** Space (loop), `a` (A/B) or `c` (context) for the audition loop. */
|
|
197
|
+
| { type: "audition"; key: AuditionKey }
|
|
198
|
+
/** Commit the staged edits as one operation. */
|
|
199
|
+
| { type: "keep" }
|
|
200
|
+
/** Drop the staged edits. */
|
|
201
|
+
| { type: "revert" }
|
|
202
|
+
/**
|
|
203
|
+
* The cursor moved in a list while the loop plays: audition `command`
|
|
204
|
+
* in place of the previous hover of `key` (one per list).
|
|
205
|
+
*/
|
|
206
|
+
| { type: "hover"; command: string; key: string }
|
|
207
|
+
/** Left a list without choosing: drop its hover (`key`). */
|
|
208
|
+
| { type: "unhover"; key: string }
|
|
209
|
+
/** Chose a list item while the loop plays: stage it for good. */
|
|
210
|
+
| { type: "choose"; command: string; key: string }
|
|
211
|
+
| { type: "pass" };
|
|
212
|
+
|
|
213
|
+
export type MenuView = Readonly<{
|
|
214
|
+
title: string;
|
|
215
|
+
items: PickerItem[];
|
|
216
|
+
index: number;
|
|
217
|
+
hint: string;
|
|
218
|
+
/** What the focused row does, and the prompt command it stands for. */
|
|
219
|
+
note: string;
|
|
220
|
+
}>;
|
|
221
|
+
|
|
222
|
+
// ── formatting and steps ──────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export function num(value: number): string {
|
|
225
|
+
return String(Math.round(value * 1000) / 1000);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const linear =
|
|
229
|
+
(size: number, min: number, max: number) =>
|
|
230
|
+
(value: number, direction: 1 | -1) =>
|
|
231
|
+
clamp(Math.round((value + size * direction) / size) * size, min, max);
|
|
232
|
+
|
|
233
|
+
/** Cutoff moves by thirds of an octave-ish so the low end stays usable. */
|
|
234
|
+
const cutoffStep = (value: number, direction: 1 | -1) =>
|
|
235
|
+
clamp(
|
|
236
|
+
Math.round(direction > 0 ? value * 1.25 : value / 1.25),
|
|
237
|
+
SCORE_LIMITS.minFilterCutoff,
|
|
238
|
+
SCORE_LIMITS.maxFilterCutoff,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const DELAY_BEATS = [
|
|
242
|
+
0.0625, 0.125, 0.1875, 0.25, 0.375, 0.5, 0.75, 1, 1.5, 2, 3, 4,
|
|
243
|
+
] as const;
|
|
244
|
+
const delayStep = (value: number, direction: 1 | -1) => {
|
|
245
|
+
if (direction > 0)
|
|
246
|
+
return DELAY_BEATS.find((beats) => beats > value + 1e-9) ?? 4;
|
|
247
|
+
return (
|
|
248
|
+
[...DELAY_BEATS].reverse().find((beats) => beats < value - 1e-9) ?? 0.0625
|
|
249
|
+
);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function clamp(value: number, min: number, max: number): number {
|
|
253
|
+
return Math.min(max, Math.max(min, value));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const TRACK_LANE_STEP: Readonly<
|
|
257
|
+
Record<TrackAutomationParameter, (value: number, direction: 1 | -1) => number>
|
|
258
|
+
> = {
|
|
259
|
+
volume: linear(0.05, 0, SCORE_LIMITS.maxVolume),
|
|
260
|
+
pan: linear(0.1, -1, 1),
|
|
261
|
+
filter: cutoffStep,
|
|
262
|
+
resonance: linear(0.05, 0, SCORE_LIMITS.maxFilterResonance),
|
|
263
|
+
"delay-feedback": linear(0.05, 0, SCORE_LIMITS.maxDelayFeedback),
|
|
264
|
+
"delay-mix": linear(0.05, 0, SCORE_LIMITS.maxDelayMix),
|
|
265
|
+
wt: linear(0.05, 0, 1),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
269
|
+
volume: "volume",
|
|
270
|
+
pan: "pan",
|
|
271
|
+
filter: "filter cutoff (Hz)",
|
|
272
|
+
resonance: "filter resonance",
|
|
273
|
+
"delay-feedback": "delay feedback",
|
|
274
|
+
"delay-mix": "delay mix",
|
|
275
|
+
wt: "wavetable position",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
279
|
+
|
|
280
|
+
/** Nudge for a number spec: linear by its step, or a sixth of an octave. */
|
|
281
|
+
function specStep(
|
|
282
|
+
spec: NumberParam,
|
|
283
|
+
): (value: number, direction: 1 | -1) => number {
|
|
284
|
+
if (spec.step === "log")
|
|
285
|
+
return (value, direction) => {
|
|
286
|
+
const next = Math.max(value, 1) * 2 ** (direction / 6);
|
|
287
|
+
return clamp(
|
|
288
|
+
next >= 100 ? Math.round(next) : Math.round(next * 10) / 10,
|
|
289
|
+
spec.min,
|
|
290
|
+
spec.max,
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
const size = spec.step;
|
|
294
|
+
return (value, direction) => {
|
|
295
|
+
const next = Math.round((value + size * direction) / size) * size;
|
|
296
|
+
return clamp(Number(next.toFixed(6)), spec.min, spec.max);
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function laneStep(
|
|
301
|
+
lane: AutomationParameter,
|
|
302
|
+
): (value: number, direction: 1 | -1) => number {
|
|
303
|
+
if (isTrackAutomationParameter(lane)) return TRACK_LANE_STEP[lane];
|
|
304
|
+
const info = FX_LANE_INFO.get(lane);
|
|
305
|
+
return info ? specStep(info.spec) : linear(0.05, 0, 1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function laneLabel(lane: AutomationParameter): string {
|
|
309
|
+
if (isTrackAutomationParameter(lane)) return TRACK_LANE_LABEL[lane];
|
|
310
|
+
const info = FX_LANE_INFO.get(lane);
|
|
311
|
+
if (!info) return lane;
|
|
312
|
+
const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
|
|
313
|
+
const owner =
|
|
314
|
+
info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
|
|
315
|
+
return `${owner} ${info.param}${unit}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `volume 0.8`, `pan -0.3`: the command that sets a lane's static value. */
|
|
319
|
+
function laneFormat(lane: AutomationParameter, value: number): string {
|
|
320
|
+
if (lane === "filter") return `${Math.round(value)}`;
|
|
321
|
+
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
322
|
+
if (info?.spec.step === "log" && value >= 100) return `${Math.round(value)}`;
|
|
323
|
+
return num(value);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── the tree ──────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function focused(context: MenuContext): Track | undefined {
|
|
329
|
+
return context.score.tracks.find((track) => track.id === context.trackId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function rootNodes(context: MenuContext): MenuNode[] {
|
|
333
|
+
const track = focused(context);
|
|
334
|
+
const name = track?.name ?? context.trackId;
|
|
335
|
+
const effects = EFFECT_NAMES.filter((effect) => effectValues(track, effect));
|
|
336
|
+
const automated = AUTOMATION_PARAMETERS.filter(
|
|
337
|
+
(lane) => automationPoints(track, lane).length > 0,
|
|
338
|
+
).length;
|
|
339
|
+
const tracks = context.score.tracks.length;
|
|
340
|
+
return [
|
|
341
|
+
{
|
|
342
|
+
kind: "menu",
|
|
343
|
+
id: "sound",
|
|
344
|
+
label: "Sound",
|
|
345
|
+
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
346
|
+
help: "the focused track's instrument and its voice",
|
|
347
|
+
build: soundSectionNodes,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
kind: "menu",
|
|
351
|
+
id: "effects",
|
|
352
|
+
label: "Effects",
|
|
353
|
+
detail: effects.length ? effects.join(" · ") : "none on",
|
|
354
|
+
help: "filter, delay, reverb and more on the focused track",
|
|
355
|
+
build: effectNodes,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
kind: "menu",
|
|
359
|
+
id: "rhythm",
|
|
360
|
+
label: "Rhythm",
|
|
361
|
+
detail: "euclid editor · patterns · kits",
|
|
362
|
+
help: "drum hits per voice, ready-made grooves and drum kits",
|
|
363
|
+
build: rhythmNodes,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
kind: "menu",
|
|
367
|
+
id: "chords",
|
|
368
|
+
label: "Chords",
|
|
369
|
+
detail: chordsDetail(context),
|
|
370
|
+
help: "song key and how chords play in play mode (Ctrl-P)",
|
|
371
|
+
build: chordNodes,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
kind: "menu",
|
|
375
|
+
id: "mix",
|
|
376
|
+
label: "Mix & automation",
|
|
377
|
+
detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
|
|
378
|
+
help: "volume, pan, mute and solo per track; values that change over time",
|
|
379
|
+
build: mixSectionNodes,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
kind: "menu",
|
|
383
|
+
id: "project",
|
|
384
|
+
label: "Project",
|
|
385
|
+
detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
|
|
386
|
+
help: "tempo, meter, loop length, play, click and count-in",
|
|
387
|
+
build: transportNodes,
|
|
388
|
+
},
|
|
389
|
+
];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Old section names still open the menu where that content now lives. */
|
|
393
|
+
const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
394
|
+
sound: ["sound"],
|
|
395
|
+
parameters: ["sound"],
|
|
396
|
+
sounds: ["sound", "browse"],
|
|
397
|
+
effects: ["effects"],
|
|
398
|
+
rhythm: ["rhythm"],
|
|
399
|
+
chords: ["chords"],
|
|
400
|
+
mix: ["mix"],
|
|
401
|
+
track: ["mix"],
|
|
402
|
+
automation: ["mix", "automation"],
|
|
403
|
+
project: ["project"],
|
|
404
|
+
transport: ["project"],
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/** Sound: instrument and voice first, then the sound browser. */
|
|
408
|
+
function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
409
|
+
return [
|
|
410
|
+
...parameterNodes(context),
|
|
411
|
+
{
|
|
412
|
+
kind: "menu",
|
|
413
|
+
id: "browse",
|
|
414
|
+
label: "browse sounds",
|
|
415
|
+
detail: soundsDetail(focused(context)),
|
|
416
|
+
help: "instruments, wavetables, sample packs",
|
|
417
|
+
build: soundNodes,
|
|
418
|
+
},
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Rhythm: the euclid editor, then grooves and kits. */
|
|
423
|
+
function rhythmNodes(): MenuNode[] {
|
|
424
|
+
return [
|
|
425
|
+
{
|
|
426
|
+
kind: "action",
|
|
427
|
+
label: "euclid editor",
|
|
428
|
+
command: "/euclid",
|
|
429
|
+
help: "hits, steps and rotation per drum voice",
|
|
430
|
+
},
|
|
431
|
+
patternsMenu(),
|
|
432
|
+
kitsMenu(),
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Mix & automation: this track, every track, then automation lanes. */
|
|
437
|
+
function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
438
|
+
const track = focused(context);
|
|
439
|
+
const automated = AUTOMATION_PARAMETERS.filter(
|
|
440
|
+
(lane) => automationPoints(track, lane).length > 0,
|
|
441
|
+
).join(" ");
|
|
442
|
+
return [
|
|
443
|
+
...trackNodes(context),
|
|
444
|
+
{
|
|
445
|
+
kind: "menu",
|
|
446
|
+
id: "tracks",
|
|
447
|
+
label: "all tracks",
|
|
448
|
+
detail: `${context.score.tracks.length} · focus another track`,
|
|
449
|
+
help: "pick a track to edit; ● is the focused one",
|
|
450
|
+
build: mixNodes,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
kind: "menu",
|
|
454
|
+
id: "automation",
|
|
455
|
+
label: "automation",
|
|
456
|
+
detail: automated || "no lanes yet",
|
|
457
|
+
help: "points that move a value over the loop",
|
|
458
|
+
build: automationNodes,
|
|
459
|
+
},
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function chordsDetail(context: MenuContext): string {
|
|
464
|
+
const chords = context.chords ?? defaultChordSettings();
|
|
465
|
+
const key = parseKey(context.score.key ?? undefined);
|
|
466
|
+
return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Play mode's chord settings and the song key (`/chords …`, `key …`). */
|
|
470
|
+
function chordNodes(context: MenuContext): MenuNode[] {
|
|
471
|
+
const chords = context.chords ?? defaultChordSettings();
|
|
472
|
+
const key = parseKey(context.score.key ?? undefined);
|
|
473
|
+
const tonic = key ? keyName(key).split(" ")[0]! : "C";
|
|
474
|
+
const mode = key?.mode ?? "major";
|
|
475
|
+
const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
|
|
476
|
+
return [
|
|
477
|
+
{
|
|
478
|
+
kind: "choice",
|
|
479
|
+
label: "mode",
|
|
480
|
+
help: "auto: keys play chords in the song key · manual: latch 1–8 · off",
|
|
481
|
+
value: chords.mode,
|
|
482
|
+
options: CHORD_MODES,
|
|
483
|
+
command: (option) => `/chords ${option}`,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
kind: "choice",
|
|
487
|
+
label: "key tonic",
|
|
488
|
+
help: "the song key; auto chord mode follows it",
|
|
489
|
+
value: tonic,
|
|
490
|
+
options: TONICS,
|
|
491
|
+
command: (option) => `key ${option} ${mode}`,
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
kind: "choice",
|
|
495
|
+
label: "key mode",
|
|
496
|
+
help: "major, minor or a church mode",
|
|
497
|
+
value: mode,
|
|
498
|
+
options: MODE_NAMES,
|
|
499
|
+
command: (option) => `key ${tonic} ${option}`,
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
kind: "number",
|
|
503
|
+
label: "voicing",
|
|
504
|
+
help: "inversion steps up or down (play mode: - =)",
|
|
505
|
+
value: chords.inversion,
|
|
506
|
+
min: -MAX_VOICING_STEP,
|
|
507
|
+
max: MAX_VOICING_STEP,
|
|
508
|
+
step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
|
|
509
|
+
format: (value) => (value > 0 ? `+${value}` : String(value)),
|
|
510
|
+
command: (value) => `/chords voicing ${Math.round(value)}`,
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
kind: "choice",
|
|
514
|
+
label: "spread",
|
|
515
|
+
help: "close, open or wide chord voicing",
|
|
516
|
+
value: chords.spread,
|
|
517
|
+
options: SPREADS,
|
|
518
|
+
command: (option) => `/chords spread ${option}`,
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
kind: "choice",
|
|
522
|
+
label: "bass",
|
|
523
|
+
help: "what the left hand plays under the chord (play mode: b)",
|
|
524
|
+
value: chords.bass,
|
|
525
|
+
options: BASS_MODES,
|
|
526
|
+
command: (option) => `/chords bass ${option}`,
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
kind: "toggle",
|
|
530
|
+
label: "sevenths",
|
|
531
|
+
help: "auto mode plays sevenths instead of triads",
|
|
532
|
+
value: chords.sevenths,
|
|
533
|
+
command: (on) => `/chords sevenths ${on ? "on" : "off"}`,
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
kind: "choice",
|
|
537
|
+
label: "perform",
|
|
538
|
+
help: "block, strum, arpeggio or a rhythm pattern (play mode: 9)",
|
|
539
|
+
value: chords.perform,
|
|
540
|
+
options: PERFORM_MODES,
|
|
541
|
+
command: (option) => `/chords perform ${option}`,
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
kind: "choice",
|
|
545
|
+
label: "pattern",
|
|
546
|
+
help: "the rhythm used when perform is pattern",
|
|
547
|
+
value: chords.pattern,
|
|
548
|
+
options: CHORD_PATTERNS.map((pattern) => pattern.name),
|
|
549
|
+
command: (option) => `/chords pattern ${option}`,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
kind: "choice",
|
|
553
|
+
label: "arp rate",
|
|
554
|
+
help: "arpeggio step length",
|
|
555
|
+
value: chords.rate,
|
|
556
|
+
options: ARP_RATES,
|
|
557
|
+
command: (option) => `/chords rate ${option}`,
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
kind: "number",
|
|
561
|
+
label: "arp octaves",
|
|
562
|
+
help: "how many octaves an arpeggio climbs",
|
|
563
|
+
value: chords.octaves,
|
|
564
|
+
min: 1,
|
|
565
|
+
max: 4,
|
|
566
|
+
step: linear(1, 1, 4),
|
|
567
|
+
format: (value) => `${num(value)} oct`,
|
|
568
|
+
command: (value) => `/chords octaves ${Math.round(value)}`,
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
kind: "choice",
|
|
572
|
+
label: "progression",
|
|
573
|
+
help: "a preset the n key walks through",
|
|
574
|
+
value: chords.preset,
|
|
575
|
+
options: ["none", ...presets],
|
|
576
|
+
command: (option) => `/chords preset ${option}`,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
kind: "choice",
|
|
580
|
+
label: "style",
|
|
581
|
+
help: "the style n suggests next chords in",
|
|
582
|
+
value: chords.style,
|
|
583
|
+
options: PROGRESSION_STYLES,
|
|
584
|
+
command: (option) => `/chords style ${option}`,
|
|
585
|
+
},
|
|
586
|
+
];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const TONICS = [
|
|
590
|
+
"C",
|
|
591
|
+
"Db",
|
|
592
|
+
"D",
|
|
593
|
+
"Eb",
|
|
594
|
+
"E",
|
|
595
|
+
"F",
|
|
596
|
+
"F#",
|
|
597
|
+
"G",
|
|
598
|
+
"Ab",
|
|
599
|
+
"A",
|
|
600
|
+
"Bb",
|
|
601
|
+
"B",
|
|
602
|
+
] as const;
|
|
603
|
+
|
|
604
|
+
function trackNodes(context: MenuContext): MenuNode[] {
|
|
605
|
+
const track = focused(context);
|
|
606
|
+
if (!track)
|
|
607
|
+
return [{ kind: "info", label: "no track", value: "/track <name>" }];
|
|
608
|
+
return [
|
|
609
|
+
{
|
|
610
|
+
kind: "entry",
|
|
611
|
+
label: "name",
|
|
612
|
+
value: track.name,
|
|
613
|
+
placeholder: "new name",
|
|
614
|
+
command: (text) =>
|
|
615
|
+
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
616
|
+
example: `track name ${track.name}`,
|
|
617
|
+
help: "rename the focused track",
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
kind: "toggle",
|
|
621
|
+
label: "mute",
|
|
622
|
+
value: track.muted,
|
|
623
|
+
command: (on) => (on ? "mute" : "unmute"),
|
|
624
|
+
help: "silence this track",
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
kind: "toggle",
|
|
628
|
+
label: "solo",
|
|
629
|
+
value: track.solo === true,
|
|
630
|
+
command: (on) => (on ? "solo" : "unsolo"),
|
|
631
|
+
help: "hear only soloed tracks",
|
|
632
|
+
},
|
|
633
|
+
volumeNode(track),
|
|
634
|
+
panNode(track),
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function instrumentNode(track: Track): MenuNode {
|
|
639
|
+
const options: string[] = [...AVAILABLE_INSTRUMENTS];
|
|
640
|
+
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
641
|
+
return {
|
|
642
|
+
kind: "choice",
|
|
643
|
+
label: "instrument",
|
|
644
|
+
value: track.instrument,
|
|
645
|
+
options,
|
|
646
|
+
command: (option) => `instrument ${option}`,
|
|
647
|
+
help: "the sound source; drums use kit",
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function volumeNode(track: Track): MenuNode {
|
|
652
|
+
return {
|
|
653
|
+
kind: "number",
|
|
654
|
+
label: "volume",
|
|
655
|
+
value: track.volume,
|
|
656
|
+
min: 0,
|
|
657
|
+
max: SCORE_LIMITS.maxVolume,
|
|
658
|
+
step: TRACK_LANE_STEP.volume,
|
|
659
|
+
format: (value) =>
|
|
660
|
+
`${num(value)} · ${value > 0 ? `${(20 * Math.log10(value)).toFixed(1)} dB` : "silent"}`,
|
|
661
|
+
command: (value) => `volume ${num(value)}`,
|
|
662
|
+
reset: "volume 1",
|
|
663
|
+
help: "track gain; 1 is unity (0 dB) · x resets",
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function panNode(track: Track): MenuNode {
|
|
668
|
+
return {
|
|
669
|
+
kind: "number",
|
|
670
|
+
label: "pan",
|
|
671
|
+
value: track.pan,
|
|
672
|
+
min: -1,
|
|
673
|
+
max: 1,
|
|
674
|
+
step: TRACK_LANE_STEP.pan,
|
|
675
|
+
format: (value) =>
|
|
676
|
+
value === 0
|
|
677
|
+
? "center"
|
|
678
|
+
: `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
|
|
679
|
+
command: (value) => `pan ${num(value)}`,
|
|
680
|
+
reset: "pan 0",
|
|
681
|
+
help: "-1 left … 1 right · x centres",
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function parameterNodes(context: MenuContext): MenuNode[] {
|
|
686
|
+
const track = focused(context);
|
|
687
|
+
if (!track) return [];
|
|
688
|
+
const nodes: MenuNode[] = [instrumentNode(track)];
|
|
689
|
+
// A wavetable track also has the synth voice's envelope, filters and FM.
|
|
690
|
+
if (isWavetableInstrument(track.instrument))
|
|
691
|
+
nodes.push(...wavetableNodes(track, context.projectRoot));
|
|
692
|
+
if (track.sampler) {
|
|
693
|
+
nodes.push({
|
|
694
|
+
kind: "info",
|
|
695
|
+
label: "sampler mode",
|
|
696
|
+
value: track.sampler.mode,
|
|
697
|
+
});
|
|
698
|
+
for (const [voice, ref] of Object.entries(track.sampler.voices).sort()) {
|
|
699
|
+
const extras = [
|
|
700
|
+
ref.root !== undefined ? `root ${ref.root}` : "",
|
|
701
|
+
ref.gain !== undefined ? `gain ${num(ref.gain)}` : "",
|
|
702
|
+
ref.speed !== undefined ? `speed ${num(ref.speed)}` : "",
|
|
703
|
+
].filter(Boolean);
|
|
704
|
+
nodes.push({
|
|
705
|
+
kind: "menu",
|
|
706
|
+
id: `sample:${voice}`,
|
|
707
|
+
label: voice,
|
|
708
|
+
detail: [ref.src.split("/").at(-1), ...extras].join(" · "),
|
|
709
|
+
help: "this voice's sample controls (Strudel names)",
|
|
710
|
+
build: (context) => sampleVoiceNodes(context, voice),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
nodes.push({
|
|
714
|
+
kind: "info",
|
|
715
|
+
label: "add a voice",
|
|
716
|
+
value: "/sample <path> [as <voice>]",
|
|
717
|
+
});
|
|
718
|
+
} else if (!isDrumInstrument(track.instrument)) {
|
|
719
|
+
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
720
|
+
nodes.push({
|
|
721
|
+
kind: "menu",
|
|
722
|
+
id: "synth:advanced",
|
|
723
|
+
label: "advanced",
|
|
724
|
+
detail: `all ${Object.keys(SYNTH_PARAMS).length} params`,
|
|
725
|
+
help: "every voice parameter, grouped, under its Strudel name",
|
|
726
|
+
build: synthAdvancedNodes,
|
|
727
|
+
});
|
|
728
|
+
if (track.synth)
|
|
729
|
+
nodes.push({
|
|
730
|
+
kind: "action",
|
|
731
|
+
label: "reset to defaults",
|
|
732
|
+
command: "synth reset",
|
|
733
|
+
help: "clear every voice setting on this track",
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return nodes;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Number rows for `/sample set`: [control, min, max, step, default]. */
|
|
740
|
+
const SAMPLE_NUMBER_ROWS: readonly (readonly [
|
|
741
|
+
SampleControl,
|
|
742
|
+
number,
|
|
743
|
+
number,
|
|
744
|
+
number,
|
|
745
|
+
number,
|
|
746
|
+
])[] = [
|
|
747
|
+
["begin", 0, 0.99, 0.01, 0],
|
|
748
|
+
["end", 0.01, 1, 0.01, 1],
|
|
749
|
+
["gain", 0, SCORE_LIMITS.maxSampleGain, 0.05, 1],
|
|
750
|
+
["speed", -SCORE_LIMITS.maxSampleSpeed, SCORE_LIMITS.maxSampleSpeed, 0.05, 1],
|
|
751
|
+
["loopBegin", 0, 0.99, 0.01, 0],
|
|
752
|
+
["loopEnd", 0.01, 1, 0.01, 1],
|
|
753
|
+
["clip", 0.05, SCORE_LIMITS.maxSampleClip, 0.05, 1],
|
|
754
|
+
[
|
|
755
|
+
"accelerate",
|
|
756
|
+
-SCORE_LIMITS.maxSampleAccelerate,
|
|
757
|
+
SCORE_LIMITS.maxSampleAccelerate,
|
|
758
|
+
0.1,
|
|
759
|
+
0,
|
|
760
|
+
],
|
|
761
|
+
["squiz", 1, SCORE_LIMITS.maxSampleSquiz, 0.5, 1],
|
|
762
|
+
];
|
|
763
|
+
|
|
764
|
+
/** One sampler voice: every sample control, run through `/sample set`. */
|
|
765
|
+
function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
766
|
+
const track = context.score.tracks.find(
|
|
767
|
+
(item) => item.id === context.trackId,
|
|
768
|
+
);
|
|
769
|
+
const ref = track?.sampler?.voices[voice];
|
|
770
|
+
if (!ref) return [];
|
|
771
|
+
const set = (rest: string) => `/sample set ${voice} ${rest}`;
|
|
772
|
+
const nodes: MenuNode[] = [{ kind: "info", label: "file", value: ref.src }];
|
|
773
|
+
for (const [control, min, max, step, start] of SAMPLE_NUMBER_ROWS) {
|
|
774
|
+
const current = (ref as Record<string, unknown>)[control];
|
|
775
|
+
nodes.push({
|
|
776
|
+
kind: "number",
|
|
777
|
+
label: control,
|
|
778
|
+
value: typeof current === "number" ? current : undefined,
|
|
779
|
+
start,
|
|
780
|
+
off: num(start),
|
|
781
|
+
min,
|
|
782
|
+
max,
|
|
783
|
+
step:
|
|
784
|
+
control === "speed"
|
|
785
|
+
? (value, direction) => {
|
|
786
|
+
// Speed 0 is invalid: step over it.
|
|
787
|
+
const next = linear(step, min, max)(value, direction);
|
|
788
|
+
return next === 0 ? step * direction : next;
|
|
789
|
+
}
|
|
790
|
+
: linear(step, min, max),
|
|
791
|
+
format: num,
|
|
792
|
+
command: (value) => set(`${control} ${num(value)}`),
|
|
793
|
+
reset: set(`${control} off`),
|
|
794
|
+
help: SAMPLE_CONTROLS[control],
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
nodes.push(
|
|
798
|
+
{
|
|
799
|
+
kind: "choice",
|
|
800
|
+
label: "unit",
|
|
801
|
+
value: ref.unit ?? "r",
|
|
802
|
+
options: SAMPLE_UNITS,
|
|
803
|
+
command: (option) => set(`unit ${option}`),
|
|
804
|
+
help: SAMPLE_CONTROLS.unit,
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
kind: "toggle",
|
|
808
|
+
label: "loop",
|
|
809
|
+
value: ref.loop === true,
|
|
810
|
+
command: (on) => set(`loop ${on ? "on" : "off"}`),
|
|
811
|
+
help: SAMPLE_CONTROLS.loop,
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
kind: "toggle",
|
|
815
|
+
label: "fit",
|
|
816
|
+
value: ref.fit === true,
|
|
817
|
+
command: (on) => set(`fit ${on ? "on" : "off"}`),
|
|
818
|
+
help: SAMPLE_CONTROLS.fit,
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
kind: "info",
|
|
822
|
+
label: "cut / loopAt",
|
|
823
|
+
value: `/sample set ${voice} cut hats · loopAt 2`,
|
|
824
|
+
},
|
|
825
|
+
);
|
|
826
|
+
return nodes;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Synth preset first, then one row per parameter. */
|
|
830
|
+
/** The preset this track's voice still matches exactly, if any. */
|
|
831
|
+
function matchingSynthPreset(track: Track): string | undefined {
|
|
832
|
+
if (!track.synth) return undefined;
|
|
833
|
+
const current = JSON.stringify(track.synth);
|
|
834
|
+
return Object.entries(SYNTH_PRESETS).find(
|
|
835
|
+
([, preset]) =>
|
|
836
|
+
preset.instrument === track.instrument &&
|
|
837
|
+
JSON.stringify(normalizeSynth(preset.synth)) === current,
|
|
838
|
+
)?.[0];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
|
|
842
|
+
const nodes: MenuNode[] = [];
|
|
843
|
+
if (keys === SYNTH_SIMPLE)
|
|
844
|
+
nodes.push({
|
|
845
|
+
kind: "choice",
|
|
846
|
+
label: "preset",
|
|
847
|
+
value: matchingSynthPreset(track) ?? "—",
|
|
848
|
+
options: Object.keys(SYNTH_PRESETS),
|
|
849
|
+
command: (preset) => `synth preset ${preset}`,
|
|
850
|
+
help: "a starting voice; every value stays editable",
|
|
851
|
+
});
|
|
852
|
+
for (const key of keys)
|
|
853
|
+
nodes.push(synthParamNode(track, key, keys !== SYNTH_SIMPLE));
|
|
854
|
+
return nodes;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
|
|
858
|
+
const param = SYNTH_PARAMS[key]!;
|
|
859
|
+
const current = track.synth?.[key];
|
|
860
|
+
const aliases = (param.strudel ?? []).filter((name) => name !== key);
|
|
861
|
+
const label = named
|
|
862
|
+
? aliases.length
|
|
863
|
+
? `${key} (${aliases.join("/")})`
|
|
864
|
+
: key
|
|
865
|
+
: (SYNTH_LABEL[key] ?? key);
|
|
866
|
+
if (param.kind === "number")
|
|
867
|
+
return {
|
|
868
|
+
kind: "number",
|
|
869
|
+
label,
|
|
870
|
+
value: typeof current === "number" ? current : undefined,
|
|
871
|
+
start: param.default,
|
|
872
|
+
// An unset filter cutoff means no filter, not the default cutoff.
|
|
873
|
+
off: /^(lpf|hpf|bpf)$/.test(key)
|
|
874
|
+
? "off"
|
|
875
|
+
: withUnit(formatParam(param, param.default), param.unit),
|
|
876
|
+
min: param.min,
|
|
877
|
+
max: param.max,
|
|
878
|
+
step: specStep(param),
|
|
879
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
880
|
+
command: (value) => `synth ${key} ${formatParam(param, value)}`,
|
|
881
|
+
reset: `synth ${key} off`,
|
|
882
|
+
help: param.doc,
|
|
883
|
+
};
|
|
884
|
+
if (param.kind === "enum")
|
|
885
|
+
return {
|
|
886
|
+
kind: "choice",
|
|
887
|
+
label,
|
|
888
|
+
value: typeof current === "string" ? current : param.default,
|
|
889
|
+
options: param.values,
|
|
890
|
+
command: (option) => `synth ${key} ${option}`,
|
|
891
|
+
help: param.doc,
|
|
892
|
+
};
|
|
893
|
+
return {
|
|
894
|
+
kind: "toggle",
|
|
895
|
+
label,
|
|
896
|
+
value: typeof current === "boolean" ? current : param.default,
|
|
897
|
+
command: (on) => `synth ${key} ${on ? "on" : "off"}`,
|
|
898
|
+
help: param.doc,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Plain names for the simple synth rows; the note shows the command name. */
|
|
903
|
+
const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
904
|
+
lpf: "filter cutoff",
|
|
905
|
+
lpq: "filter res",
|
|
906
|
+
lpenv: "filter env",
|
|
907
|
+
vib: "vibrato",
|
|
908
|
+
fm: "FM amount",
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
/** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
|
|
912
|
+
function synthAdvancedNodes(context: MenuContext): MenuNode[] {
|
|
913
|
+
const track = focused(context);
|
|
914
|
+
if (!track) return [];
|
|
915
|
+
const nodes: MenuNode[] = SYNTH_GROUPS.map((group) => {
|
|
916
|
+
const set = group.params.filter((key) => track.synth?.[key] !== undefined);
|
|
917
|
+
return {
|
|
918
|
+
kind: "menu",
|
|
919
|
+
id: `synth:${group.id}`,
|
|
920
|
+
label: group.label,
|
|
921
|
+
detail: set.length
|
|
922
|
+
? set.map((key) => `${key} ${String(track.synth![key])}`).join(" · ")
|
|
923
|
+
: "defaults",
|
|
924
|
+
build: (inner) => {
|
|
925
|
+
const current = focused(inner);
|
|
926
|
+
return current ? synthNodes(current, group.params) : [];
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
const partials = track.synth?.partials;
|
|
931
|
+
nodes.push({
|
|
932
|
+
kind: "entry",
|
|
933
|
+
label: "partials",
|
|
934
|
+
value: Array.isArray(partials) ? partials.join(" ") : "—",
|
|
935
|
+
placeholder: "harmonic amplitudes, e.g. 1 0.5 0.33",
|
|
936
|
+
command: (text) =>
|
|
937
|
+
text.trim() ? `synth partials ${text.trim()}` : "synth partials off",
|
|
938
|
+
example: "synth partials 1 0.5 0.33 0.25",
|
|
939
|
+
});
|
|
940
|
+
nodes.push({
|
|
941
|
+
kind: "entry",
|
|
942
|
+
label: "zzfx array",
|
|
943
|
+
value: "—",
|
|
944
|
+
placeholder: "a raw ZzFX array, e.g. ,,129,.01,,.15,2",
|
|
945
|
+
command: (text) => `synth zzfx ${text.trim()}`,
|
|
946
|
+
example: "synth zzfx ,,129,.01,,.15,2",
|
|
947
|
+
});
|
|
948
|
+
return nodes;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Short help for each wavetable parameter row. */
|
|
952
|
+
const WAVETABLE_LABELS: Readonly<Record<WavetableParam, string>> = {
|
|
953
|
+
wt: "position (wt)",
|
|
954
|
+
wtenv: "position env amount",
|
|
955
|
+
wtattack: "position env attack s",
|
|
956
|
+
wtdecay: "position env decay s",
|
|
957
|
+
wtsustain: "position env sustain",
|
|
958
|
+
wtrelease: "position env release s",
|
|
959
|
+
wtrate: "position LFO Hz",
|
|
960
|
+
wtdepth: "position LFO depth",
|
|
961
|
+
warp: "warp",
|
|
962
|
+
wtphaserand: "phase randomness",
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const WAVETABLE_STEP: Readonly<Record<WavetableParam, number>> = {
|
|
966
|
+
wt: 0.05,
|
|
967
|
+
wtenv: 0.1,
|
|
968
|
+
wtattack: 0.01,
|
|
969
|
+
wtdecay: 0.05,
|
|
970
|
+
wtsustain: 0.05,
|
|
971
|
+
wtrelease: 0.05,
|
|
972
|
+
wtrate: 0.25,
|
|
973
|
+
wtdepth: 0.05,
|
|
974
|
+
warp: 0.05,
|
|
975
|
+
wtphaserand: 0.1,
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
|
|
979
|
+
const settings = wavetableOf(track);
|
|
980
|
+
const local = listLocalWavetables(projectRoot);
|
|
981
|
+
const tables: MenuNode[] = [
|
|
982
|
+
...local.map((path): MenuNode => ({
|
|
983
|
+
kind: "action",
|
|
984
|
+
label: `${path.split("/").pop()} project · ${path.split("/")[1]}`,
|
|
985
|
+
command: `wt ${path}`,
|
|
986
|
+
})),
|
|
987
|
+
...BUILTIN_TABLE_NAMES.map((name): MenuNode => ({
|
|
988
|
+
kind: "action",
|
|
989
|
+
label: `${name} ${BUILTIN_TABLES[name]!.title} · built-in`,
|
|
990
|
+
command: `wt ${name}`,
|
|
991
|
+
})),
|
|
992
|
+
...Object.entries(UZU_WAVETABLES).map(([set, names]): MenuNode => ({
|
|
993
|
+
kind: "menu",
|
|
994
|
+
id: `wt-${set}`,
|
|
995
|
+
label: set,
|
|
996
|
+
detail: `${names.length} tables · ${WAVETABLE_PACK}`,
|
|
997
|
+
build: () =>
|
|
998
|
+
names.map((name, index): MenuNode => ({
|
|
999
|
+
kind: "action",
|
|
1000
|
+
label: `${set}:${index} ${name}`,
|
|
1001
|
+
command: `wt ${set}:${index}`,
|
|
1002
|
+
})),
|
|
1003
|
+
})),
|
|
1004
|
+
];
|
|
1005
|
+
const nodes: MenuNode[] = [
|
|
1006
|
+
{
|
|
1007
|
+
kind: "menu",
|
|
1008
|
+
id: "wavetables",
|
|
1009
|
+
label: "table",
|
|
1010
|
+
detail: describeTable(settings.table.src),
|
|
1011
|
+
build: () => tables,
|
|
1012
|
+
},
|
|
1013
|
+
];
|
|
1014
|
+
for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
|
|
1015
|
+
const [min, max, fallback] = WAVETABLE_PARAMS[name];
|
|
1016
|
+
nodes.push({
|
|
1017
|
+
kind: "number",
|
|
1018
|
+
label: WAVETABLE_LABELS[name],
|
|
1019
|
+
value: settings[name] ?? fallback,
|
|
1020
|
+
min,
|
|
1021
|
+
max,
|
|
1022
|
+
step: linear(WAVETABLE_STEP[name], min, max),
|
|
1023
|
+
format: num,
|
|
1024
|
+
command: (value) => `${name} ${num(value)}`,
|
|
1025
|
+
reset: `${name} ${num(fallback)}`,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
nodes.push({
|
|
1029
|
+
kind: "choice",
|
|
1030
|
+
label: "warp mode",
|
|
1031
|
+
value: settings.warpmode ?? "none",
|
|
1032
|
+
options: WARP_MODES,
|
|
1033
|
+
command: (option) => `warpmode ${option}`,
|
|
1034
|
+
});
|
|
1035
|
+
if ((track.wtAutomation?.length ?? 0) > 0)
|
|
1036
|
+
nodes.push({
|
|
1037
|
+
kind: "info",
|
|
1038
|
+
label: "position automation",
|
|
1039
|
+
value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
|
|
1040
|
+
});
|
|
1041
|
+
return nodes;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Effects in chain order; each opens on its presets and simple params. */
|
|
1045
|
+
function effectNodes(context: MenuContext): MenuNode[] {
|
|
1046
|
+
const track = focused(context);
|
|
1047
|
+
if (!track) return [];
|
|
1048
|
+
const node = (effect: EffectName): MenuNode => {
|
|
1049
|
+
const spec = effectSpec(effect);
|
|
1050
|
+
const values = effectValues(track, effect);
|
|
1051
|
+
return {
|
|
1052
|
+
kind: "menu",
|
|
1053
|
+
id: effect,
|
|
1054
|
+
label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
|
|
1055
|
+
detail: values ? effectSummary(effect, values) : "off",
|
|
1056
|
+
help: spec.doc,
|
|
1057
|
+
build: (inner) => effectParamNodes(inner, effect, false),
|
|
1058
|
+
};
|
|
1059
|
+
};
|
|
1060
|
+
const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
|
|
1061
|
+
const moreOn = more.filter((effect) => effectValues(track, effect));
|
|
1062
|
+
return [
|
|
1063
|
+
...CORE_EFFECTS.map(node),
|
|
1064
|
+
{
|
|
1065
|
+
kind: "menu",
|
|
1066
|
+
id: "more effects",
|
|
1067
|
+
label: "more effects",
|
|
1068
|
+
help: "less common effects, each with the same controls",
|
|
1069
|
+
detail:
|
|
1070
|
+
moreOn.length > 0
|
|
1071
|
+
? `on: ${moreOn.map((effect) => effectSpec(effect).label).join(", ")}`
|
|
1072
|
+
: more.map((effect) => effectSpec(effect).label).join(", "),
|
|
1073
|
+
build: () => more.map(node),
|
|
1074
|
+
},
|
|
1075
|
+
];
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function effectSummary(
|
|
1079
|
+
effect: EffectName,
|
|
1080
|
+
values: Readonly<Record<string, number | string | boolean>>,
|
|
1081
|
+
): string {
|
|
1082
|
+
const spec = effectSpec(effect);
|
|
1083
|
+
return spec.simple
|
|
1084
|
+
.filter((key) => values[key] !== undefined)
|
|
1085
|
+
.map((key) => {
|
|
1086
|
+
const value = values[key]!;
|
|
1087
|
+
const param = spec.params[key]!;
|
|
1088
|
+
return `${key} ${typeof value === "number" ? formatParam(param, value) : String(value)}`;
|
|
1089
|
+
})
|
|
1090
|
+
.join(" · ");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** `800 Hz`, `0.2 s`: a value with its unit, when it has one. */
|
|
1094
|
+
function withUnit(text: string, unit: string | undefined): string {
|
|
1095
|
+
return unit ? `${text} ${unit}` : text;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function formatParam(spec: ParamSpec, value: number): string {
|
|
1099
|
+
if (spec.kind !== "number") return num(value);
|
|
1100
|
+
return spec.step === "log" && value >= 100
|
|
1101
|
+
? `${Math.round(value)}`
|
|
1102
|
+
: num(value);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* One effect's rows: on/off, presets, then its simple parameters; the
|
|
1107
|
+
* `advanced` submenu holds every parameter with its Strudel names.
|
|
1108
|
+
*/
|
|
1109
|
+
function effectParamNodes(
|
|
1110
|
+
context: MenuContext,
|
|
1111
|
+
effect: EffectName,
|
|
1112
|
+
advanced: boolean,
|
|
1113
|
+
): MenuNode[] {
|
|
1114
|
+
const track = focused(context);
|
|
1115
|
+
if (!track) return [];
|
|
1116
|
+
const spec = effectSpec(effect);
|
|
1117
|
+
const values = effectValues(track, effect);
|
|
1118
|
+
const nodes: MenuNode[] = [];
|
|
1119
|
+
if (!advanced) {
|
|
1120
|
+
nodes.push({
|
|
1121
|
+
kind: "toggle",
|
|
1122
|
+
label: "on",
|
|
1123
|
+
value: values !== undefined,
|
|
1124
|
+
command: (on) => `fx ${effect} ${on ? "on" : "off"}`,
|
|
1125
|
+
help: `switch the ${spec.label} on or off (settings are kept)`,
|
|
1126
|
+
});
|
|
1127
|
+
const presets = effectPresetNames(effect);
|
|
1128
|
+
if (presets.length > 0)
|
|
1129
|
+
nodes.push({
|
|
1130
|
+
kind: "choice",
|
|
1131
|
+
label: "preset",
|
|
1132
|
+
value: "—",
|
|
1133
|
+
options: presets,
|
|
1134
|
+
command: (preset) => `fx ${effect} preset ${preset}`,
|
|
1135
|
+
help: "a starting point; every value stays editable",
|
|
1136
|
+
});
|
|
1137
|
+
if (effect === "reverb") {
|
|
1138
|
+
// Convolution (Strudel `ir`): a generated impulse, or `fx ir <file>`.
|
|
1139
|
+
const ir = track.reverb?.ir?.src;
|
|
1140
|
+
const current = ir?.startsWith("builtin:")
|
|
1141
|
+
? ir.slice("builtin:".length)
|
|
1142
|
+
: (ir ?? "off");
|
|
1143
|
+
nodes.push({
|
|
1144
|
+
kind: "choice",
|
|
1145
|
+
label: "impulse (ir)",
|
|
1146
|
+
value: current,
|
|
1147
|
+
options: ["off", ...REVERB_IR_BUILTINS],
|
|
1148
|
+
command: (choice) => `fx reverb ir ${choice}`,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const keys = advanced ? Object.keys(spec.params) : spec.simple;
|
|
1153
|
+
for (const key of keys) {
|
|
1154
|
+
const param = spec.params[key]!;
|
|
1155
|
+
const current = values?.[key];
|
|
1156
|
+
const label =
|
|
1157
|
+
advanced && param.strudel?.length
|
|
1158
|
+
? `${key} (${param.strudel.filter((name) => !name.includes(" ")).join("/")})`
|
|
1159
|
+
: key;
|
|
1160
|
+
if (param.kind === "number") {
|
|
1161
|
+
nodes.push({
|
|
1162
|
+
kind: "number",
|
|
1163
|
+
label,
|
|
1164
|
+
value: typeof current === "number" ? current : undefined,
|
|
1165
|
+
start: param.default,
|
|
1166
|
+
off: values
|
|
1167
|
+
? withUnit(formatParam(param, param.default), param.unit)
|
|
1168
|
+
: "off",
|
|
1169
|
+
min: param.min,
|
|
1170
|
+
max: param.max,
|
|
1171
|
+
step: specStep(param),
|
|
1172
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
1173
|
+
command: (value) => `fx ${effect} ${key} ${formatParam(param, value)}`,
|
|
1174
|
+
help: param.doc,
|
|
1175
|
+
...(values
|
|
1176
|
+
? {
|
|
1177
|
+
reset: `fx ${effect} ${key} ${formatParam(param, param.default)}`,
|
|
1178
|
+
}
|
|
1179
|
+
: {}),
|
|
1180
|
+
});
|
|
1181
|
+
} else if (param.kind === "enum") {
|
|
1182
|
+
nodes.push({
|
|
1183
|
+
kind: "choice",
|
|
1184
|
+
label,
|
|
1185
|
+
value: typeof current === "string" ? current : param.default,
|
|
1186
|
+
options: param.values,
|
|
1187
|
+
command: (option) => `fx ${effect} ${key} ${option}`,
|
|
1188
|
+
help: param.doc,
|
|
1189
|
+
});
|
|
1190
|
+
} else {
|
|
1191
|
+
nodes.push({
|
|
1192
|
+
kind: "toggle",
|
|
1193
|
+
label,
|
|
1194
|
+
value: typeof current === "boolean" ? current : param.default,
|
|
1195
|
+
command: (on) => `fx ${effect} ${key} ${on ? "on" : "off"}`,
|
|
1196
|
+
help: param.doc,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (!advanced) {
|
|
1201
|
+
nodes.push({
|
|
1202
|
+
kind: "menu",
|
|
1203
|
+
id: `${effect}:advanced`,
|
|
1204
|
+
label: "advanced",
|
|
1205
|
+
detail: `all ${Object.keys(spec.params).length} params`,
|
|
1206
|
+
help: `Strudel: ${spec.strudel}`,
|
|
1207
|
+
build: (inner) => effectParamNodes(inner, effect, true),
|
|
1208
|
+
});
|
|
1209
|
+
if (values)
|
|
1210
|
+
nodes.push({
|
|
1211
|
+
kind: "action",
|
|
1212
|
+
label: "reset to defaults",
|
|
1213
|
+
command: `fx ${effect} reset`,
|
|
1214
|
+
help: `put every ${spec.label} value back to its default`,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return nodes;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function automationNodes(context: MenuContext): MenuNode[] {
|
|
1221
|
+
const track = focused(context);
|
|
1222
|
+
if (!track) return [];
|
|
1223
|
+
// Track lanes, the lanes of effects that are on, and any lane with
|
|
1224
|
+
// points; every other effect lane is under "all lanes".
|
|
1225
|
+
const shown = AUTOMATION_PARAMETERS.filter((lane) => {
|
|
1226
|
+
if (isTrackAutomationParameter(lane)) return true;
|
|
1227
|
+
if (automationPoints(track, lane).length > 0) return true;
|
|
1228
|
+
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
1229
|
+
if (info?.effect === "synth")
|
|
1230
|
+
return track.synth?.[info.param] !== undefined;
|
|
1231
|
+
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
1232
|
+
});
|
|
1233
|
+
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
1234
|
+
const nodes = shown.map((lane) => laneMenu(track, lane));
|
|
1235
|
+
if (hidden.length > 0)
|
|
1236
|
+
nodes.push({
|
|
1237
|
+
kind: "menu",
|
|
1238
|
+
id: "lanes:all",
|
|
1239
|
+
label: "all lanes",
|
|
1240
|
+
detail: `${hidden.length} more effect lanes`,
|
|
1241
|
+
build: (inner) => {
|
|
1242
|
+
const current = focused(inner);
|
|
1243
|
+
return current ? hidden.map((lane) => laneMenu(current, lane)) : [];
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
return nodes;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function laneMenu(track: Track, lane: AutomationParameter): MenuNode {
|
|
1250
|
+
const count = automationPoints(track, lane).length;
|
|
1251
|
+
return {
|
|
1252
|
+
kind: "menu",
|
|
1253
|
+
id: `lane:${lane}`,
|
|
1254
|
+
label: laneLabel(lane),
|
|
1255
|
+
detail: `${count} point${count === 1 ? "" : "s"}`,
|
|
1256
|
+
build: (inner) => laneNodes(inner, lane),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** `4:0.5 8:1` → `automate <lane> points 4:0.5 8:1`, when every pair parses. */
|
|
1261
|
+
function pointsCommand(
|
|
1262
|
+
lane: AutomationParameter,
|
|
1263
|
+
text: string,
|
|
1264
|
+
exactly?: number,
|
|
1265
|
+
): string | undefined {
|
|
1266
|
+
const pairs = text
|
|
1267
|
+
.trim()
|
|
1268
|
+
.split(/[\s,]+/)
|
|
1269
|
+
.filter(Boolean);
|
|
1270
|
+
if (pairs.length === 0 || (exactly !== undefined && pairs.length !== exactly))
|
|
1271
|
+
return undefined;
|
|
1272
|
+
const { min, max } = automationRange(lane);
|
|
1273
|
+
for (const pair of pairs) {
|
|
1274
|
+
const match = pair.match(/^(\d+(?:\.\d+)?):(-?\d+(?:\.\d+)?)$/);
|
|
1275
|
+
if (!match) return undefined;
|
|
1276
|
+
const value = Number(match[2]);
|
|
1277
|
+
if (value < min || value > max) return undefined;
|
|
1278
|
+
}
|
|
1279
|
+
return `automate ${lane} points ${pairs.join(" ")}`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function laneNodes(
|
|
1283
|
+
context: MenuContext,
|
|
1284
|
+
lane: AutomationParameter,
|
|
1285
|
+
): MenuNode[] {
|
|
1286
|
+
const track = focused(context);
|
|
1287
|
+
if (!track) return [];
|
|
1288
|
+
const { min, max } = automationRange(lane);
|
|
1289
|
+
const range = `${laneFormat(lane, min)}…${laneFormat(lane, max)}`;
|
|
1290
|
+
const points = automationPoints(track, lane);
|
|
1291
|
+
const nodes: MenuNode[] = [
|
|
1292
|
+
{
|
|
1293
|
+
kind: "entry",
|
|
1294
|
+
label: "add points",
|
|
1295
|
+
value: range,
|
|
1296
|
+
placeholder: "beat:value [beat:value …]",
|
|
1297
|
+
command: (text) => pointsCommand(lane, text),
|
|
1298
|
+
example: `automate ${lane} points 0:${laneFormat(lane, min)} 4:${laneFormat(lane, max)}`,
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
kind: "entry",
|
|
1302
|
+
label: "ramp",
|
|
1303
|
+
value: "two points",
|
|
1304
|
+
placeholder: "from-beat:value to-beat:value",
|
|
1305
|
+
command: (text) => pointsCommand(lane, text, 2),
|
|
1306
|
+
example: `automate ${lane} points 0:${laneFormat(lane, min)} ${context.score.beatsPerBar * context.score.bars}:${laneFormat(lane, max)}`,
|
|
1307
|
+
},
|
|
1308
|
+
];
|
|
1309
|
+
for (const point of points)
|
|
1310
|
+
nodes.push({
|
|
1311
|
+
kind: "point",
|
|
1312
|
+
label: `beat ${num(point.tick / context.score.ticksPerBeat)}`,
|
|
1313
|
+
lane,
|
|
1314
|
+
beat: point.tick / context.score.ticksPerBeat,
|
|
1315
|
+
value: point.value,
|
|
1316
|
+
});
|
|
1317
|
+
if (points.length > 0)
|
|
1318
|
+
nodes.push({
|
|
1319
|
+
kind: "action",
|
|
1320
|
+
label: "clear lane",
|
|
1321
|
+
command: `clear ${lane} automation`,
|
|
1322
|
+
});
|
|
1323
|
+
return nodes;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function mixNodes(context: MenuContext): MenuNode[] {
|
|
1327
|
+
return context.score.tracks.map((track): MenuNode => {
|
|
1328
|
+
const detail = `vol ${num(track.volume)} · pan ${num(track.pan)}${track.muted ? " · M" : ""}${track.solo ? " · S" : ""}`;
|
|
1329
|
+
// Commands act on the focused track, so another track is focused first.
|
|
1330
|
+
return track.id === context.trackId
|
|
1331
|
+
? {
|
|
1332
|
+
kind: "menu",
|
|
1333
|
+
id: `mix:${track.id}`,
|
|
1334
|
+
label: `${track.name} ●`,
|
|
1335
|
+
detail,
|
|
1336
|
+
build: trackNodes,
|
|
1337
|
+
}
|
|
1338
|
+
: {
|
|
1339
|
+
kind: "action",
|
|
1340
|
+
label: `${track.name} ${detail}`,
|
|
1341
|
+
command: `/track ${track.id}`,
|
|
1342
|
+
};
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ── sounds (sample packs) ──────────────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
function soundsDetail(track: Track | undefined): string {
|
|
1349
|
+
const packs = new Set<string>();
|
|
1350
|
+
for (const ref of Object.values(track?.sampler?.voices ?? {}))
|
|
1351
|
+
if (ref.src.startsWith("pack:")) packs.add(ref.src.slice(5).split("/")[0]!);
|
|
1352
|
+
return packs.size ? [...packs].join(" · ") : "kits, instruments, packs";
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The instrument browser: drum kits and soundfont instruments from the
|
|
1357
|
+
* built-in packs (fetched on first use), and the pack list.
|
|
1358
|
+
*/
|
|
1359
|
+
function kitsMenu(): MenuNode {
|
|
1360
|
+
return {
|
|
1361
|
+
kind: "menu",
|
|
1362
|
+
id: "kits",
|
|
1363
|
+
label: "drum kits",
|
|
1364
|
+
help: "make this a drum track with a synth or sample kit",
|
|
1365
|
+
detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
|
|
1366
|
+
// Synth kits first (offline), then the pack sample kits.
|
|
1367
|
+
build: () => [
|
|
1368
|
+
...kitCatalog().map((entry): MenuNode => ({
|
|
1369
|
+
kind: "action",
|
|
1370
|
+
label:
|
|
1371
|
+
entry.kind === "synth"
|
|
1372
|
+
? `${entry.label} · ${entry.detail}`
|
|
1373
|
+
: `${entry.label} · ${DEFAULT_KITS[entry.name]?.pack ?? entry.detail}`,
|
|
1374
|
+
command: entry.command,
|
|
1375
|
+
})),
|
|
1376
|
+
{
|
|
1377
|
+
kind: "menu",
|
|
1378
|
+
id: "kit-nicknames",
|
|
1379
|
+
label: "Strudel banks",
|
|
1380
|
+
detail: `${Object.keys(STRUDEL_BANK_ALIASES).length} drum machines by nickname`,
|
|
1381
|
+
build: () =>
|
|
1382
|
+
Object.entries(STRUDEL_BANK_ALIASES)
|
|
1383
|
+
.sort(([, a], [, b]) =>
|
|
1384
|
+
a.toLowerCase() < b.toLowerCase() ? -1 : 1,
|
|
1385
|
+
)
|
|
1386
|
+
.map(([bank, nickname]): MenuNode => ({
|
|
1387
|
+
kind: "action",
|
|
1388
|
+
label: `${nickname} ${bank}`,
|
|
1389
|
+
command: `/kit ${nickname}`,
|
|
1390
|
+
})),
|
|
1391
|
+
},
|
|
1392
|
+
],
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function patternsMenu(): MenuNode {
|
|
1397
|
+
return {
|
|
1398
|
+
kind: "menu",
|
|
1399
|
+
id: "patterns",
|
|
1400
|
+
label: "drum patterns",
|
|
1401
|
+
detail: `${DRUM_PATTERNS.length} grooves`,
|
|
1402
|
+
help: "replace the drum part with a ready-made groove",
|
|
1403
|
+
build: () =>
|
|
1404
|
+
DRUM_PATTERNS.map((entry): MenuNode => ({
|
|
1405
|
+
kind: "action",
|
|
1406
|
+
label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
1407
|
+
command: `/pattern ${entry.name}`,
|
|
1408
|
+
})),
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function soundNodes(): MenuNode[] {
|
|
1413
|
+
return [
|
|
1414
|
+
{
|
|
1415
|
+
kind: "action",
|
|
1416
|
+
label: "wavetable synth basic shapes morph · built-in",
|
|
1417
|
+
command: "wt basic",
|
|
1418
|
+
help: "switch this track to the morphing wavetable synth",
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
kind: "menu",
|
|
1422
|
+
id: "instruments",
|
|
1423
|
+
label: "instruments",
|
|
1424
|
+
help: "sampled piano and General MIDI instruments",
|
|
1425
|
+
detail: "General MIDI soundfont, piano",
|
|
1426
|
+
build: () => [
|
|
1427
|
+
{
|
|
1428
|
+
kind: "action",
|
|
1429
|
+
label: "piano Salamander grand · piano",
|
|
1430
|
+
command: "/pack use piano/piano",
|
|
1431
|
+
},
|
|
1432
|
+
...GM_INSTRUMENTS.map((name): MenuNode => ({
|
|
1433
|
+
kind: "action",
|
|
1434
|
+
label: `${name.replace(/^gm_/, "").replace(/_/g, " ")} · gm`,
|
|
1435
|
+
command: `/pack use gm/${name}`,
|
|
1436
|
+
})),
|
|
1437
|
+
],
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
kind: "entry",
|
|
1441
|
+
label: "use a sound",
|
|
1442
|
+
value: "",
|
|
1443
|
+
help: "type a pack sound name, e.g. dirt-samples/bd:3",
|
|
1444
|
+
placeholder: "<pack>/<sound>[:<n>]",
|
|
1445
|
+
command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
|
|
1446
|
+
example: "/pack use dirt-samples/bd:3",
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
kind: "menu",
|
|
1450
|
+
id: "packs",
|
|
1451
|
+
label: "sample packs",
|
|
1452
|
+
help: "browse built-in packs or add one by URL",
|
|
1453
|
+
detail: `${PACK_CATALOG.length} built in`,
|
|
1454
|
+
build: () => [
|
|
1455
|
+
...PACK_CATALOG.map((pack): MenuNode => ({
|
|
1456
|
+
kind: "action",
|
|
1457
|
+
label: `${pack.name} ${pack.license}`,
|
|
1458
|
+
command: `/pack info ${pack.name}`,
|
|
1459
|
+
})),
|
|
1460
|
+
{
|
|
1461
|
+
kind: "entry",
|
|
1462
|
+
label: "add a pack",
|
|
1463
|
+
value: "",
|
|
1464
|
+
placeholder: "manifest URL or github:user/repo",
|
|
1465
|
+
command: (text) =>
|
|
1466
|
+
text.trim() ? `/pack add ${text.trim()}` : undefined,
|
|
1467
|
+
example: "/pack add github:yaxu/clean-breaks",
|
|
1468
|
+
},
|
|
1469
|
+
],
|
|
1470
|
+
},
|
|
1471
|
+
];
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function transportNodes(context: MenuContext): MenuNode[] {
|
|
1475
|
+
const score = context.score;
|
|
1476
|
+
return [
|
|
1477
|
+
{
|
|
1478
|
+
kind: "action",
|
|
1479
|
+
label: context.playing ? "pause" : "play",
|
|
1480
|
+
command: context.playing ? "pause" : "play",
|
|
1481
|
+
help: "start or stop the loop (space on an empty prompt)",
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
kind: "number",
|
|
1485
|
+
label: "tempo",
|
|
1486
|
+
help: "beats per minute",
|
|
1487
|
+
value: score.tempoBpm,
|
|
1488
|
+
min: SCORE_LIMITS.minTempoBpm,
|
|
1489
|
+
max: SCORE_LIMITS.maxTempoBpm,
|
|
1490
|
+
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
1491
|
+
format: (value) => `${num(value)} BPM`,
|
|
1492
|
+
command: (value) => `tempo ${num(value)}`,
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
kind: "number",
|
|
1496
|
+
label: "beats per bar",
|
|
1497
|
+
help: "the meter",
|
|
1498
|
+
value: score.beatsPerBar,
|
|
1499
|
+
min: 1,
|
|
1500
|
+
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
1501
|
+
step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
|
|
1502
|
+
format: (value) => `${value}/4`,
|
|
1503
|
+
command: (value) => `meter ${Math.round(value)}`,
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
kind: "number",
|
|
1507
|
+
label: "loop length",
|
|
1508
|
+
help: "how long the loop is",
|
|
1509
|
+
value: score.bars,
|
|
1510
|
+
min: 1,
|
|
1511
|
+
max: SCORE_LIMITS.maxBars,
|
|
1512
|
+
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
1513
|
+
format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
|
|
1514
|
+
command: (value) => `bars ${Math.round(value)}`,
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
kind: "choice",
|
|
1518
|
+
label: "grid",
|
|
1519
|
+
help: "the step play-mode recording and the euclid editor snap to",
|
|
1520
|
+
value: context.grid,
|
|
1521
|
+
options: context.grids,
|
|
1522
|
+
command: (option) => `/grid ${option}`,
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
kind: "toggle",
|
|
1526
|
+
label: "click",
|
|
1527
|
+
help: "metronome while playing and recording",
|
|
1528
|
+
value: context.clickOn,
|
|
1529
|
+
command: (on) => `/click ${on ? "on" : "off"}`,
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
kind: "choice",
|
|
1533
|
+
label: "count-in bars",
|
|
1534
|
+
help: "bars of click before recording starts",
|
|
1535
|
+
value: String(context.countInBars),
|
|
1536
|
+
options: ["0", "1", "2"],
|
|
1537
|
+
command: (option) => `/count-in ${option}`,
|
|
1538
|
+
},
|
|
1539
|
+
];
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// ── controller ────────────────────────────────────────────────────────
|
|
1543
|
+
|
|
1544
|
+
type Frame = {
|
|
1545
|
+
title: string;
|
|
1546
|
+
build: (context: MenuContext) => MenuNode[];
|
|
1547
|
+
index: number;
|
|
1548
|
+
query: string;
|
|
1549
|
+
/**
|
|
1550
|
+
* Lists audition the item under the cursor while the loop plays: the
|
|
1551
|
+
* hover key, and whether this frame has staged a hover yet.
|
|
1552
|
+
*/
|
|
1553
|
+
hover?: string;
|
|
1554
|
+
hovering?: boolean;
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
|
|
1558
|
+
const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
|
|
1559
|
+
const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
1560
|
+
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
1561
|
+
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
1562
|
+
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
1563
|
+
const KEY_DELETE = new Set(["\u001b[3~", "x"]);
|
|
1564
|
+
const LABEL_WIDTH = 16;
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* A submenu frame whose rows are rebuilt from the parent's fresh build, so
|
|
1568
|
+
* values (and the commands closed over them) always follow the score.
|
|
1569
|
+
*/
|
|
1570
|
+
function childFrame(
|
|
1571
|
+
parent: Frame,
|
|
1572
|
+
node: Extract<MenuNode, { kind: "menu" }>,
|
|
1573
|
+
): Frame {
|
|
1574
|
+
return {
|
|
1575
|
+
title: node.label,
|
|
1576
|
+
index: 0,
|
|
1577
|
+
query: "",
|
|
1578
|
+
hover: `menu:${node.id}`,
|
|
1579
|
+
build: (context) => {
|
|
1580
|
+
const fresh = parent
|
|
1581
|
+
.build(context)
|
|
1582
|
+
.find(
|
|
1583
|
+
(candidate): candidate is Extract<MenuNode, { kind: "menu" }> =>
|
|
1584
|
+
candidate.kind === "menu" && candidate.id === node.id,
|
|
1585
|
+
);
|
|
1586
|
+
return (fresh ?? node).build(context);
|
|
1587
|
+
},
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
export class EditMenu {
|
|
1592
|
+
private stack: Frame[] = [];
|
|
1593
|
+
private filtering = false;
|
|
1594
|
+
/** Typed value for the selected row (`entry` holds the row's label). */
|
|
1595
|
+
private entry: { label: string; buffer: string } | undefined;
|
|
1596
|
+
|
|
1597
|
+
get open(): boolean {
|
|
1598
|
+
return this.stack.length > 0;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** True while the menu takes typed text (a filter or a value). */
|
|
1602
|
+
get typing(): boolean {
|
|
1603
|
+
return this.filtering || this.entry !== undefined;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** Open at the root, or at a section id (`effects`, `automation`). */
|
|
1607
|
+
show(context: MenuContext, section?: string): void {
|
|
1608
|
+
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
1609
|
+
this.filtering = false;
|
|
1610
|
+
this.entry = undefined;
|
|
1611
|
+
// Walk the section path (`automation` is Mix & automation › automation).
|
|
1612
|
+
for (const id of section
|
|
1613
|
+
? (SECTION_ALIASES[section.toLowerCase()] ?? [])
|
|
1614
|
+
: []) {
|
|
1615
|
+
const frame = this.stack.at(-1)!;
|
|
1616
|
+
const nodes = frame.build(context);
|
|
1617
|
+
const index = nodes.findIndex(
|
|
1618
|
+
(node) => node.kind === "menu" && node.id === id,
|
|
1619
|
+
);
|
|
1620
|
+
const node = nodes[index];
|
|
1621
|
+
if (node?.kind !== "menu") break;
|
|
1622
|
+
frame.index = index;
|
|
1623
|
+
this.stack.push(childFrame(frame, node));
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
close(): void {
|
|
1628
|
+
this.stack = [];
|
|
1629
|
+
this.filtering = false;
|
|
1630
|
+
this.entry = undefined;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/** The visible rows of the current level (filtered). */
|
|
1634
|
+
nodes(context: MenuContext): MenuNode[] {
|
|
1635
|
+
const frame = this.stack.at(-1);
|
|
1636
|
+
if (!frame) return [];
|
|
1637
|
+
const all = frame.build(context);
|
|
1638
|
+
const needle = frame.query.toLowerCase();
|
|
1639
|
+
return needle
|
|
1640
|
+
? all.filter((node) => nodeText(node).toLowerCase().includes(needle))
|
|
1641
|
+
: all;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
private selected(context: MenuContext): MenuNode | undefined {
|
|
1645
|
+
const frame = this.stack.at(-1);
|
|
1646
|
+
const nodes = this.nodes(context);
|
|
1647
|
+
if (!frame || nodes.length === 0) return undefined;
|
|
1648
|
+
frame.index = clamp(frame.index, 0, nodes.length - 1);
|
|
1649
|
+
return nodes[frame.index];
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
key(value: string, context: MenuContext): MenuResult {
|
|
1653
|
+
const frame = this.stack.at(-1);
|
|
1654
|
+
if (!frame) return { type: "pass" };
|
|
1655
|
+
if (value === "\u0003" || value === "\u000c") return { type: "pass" };
|
|
1656
|
+
if (this.entry) return this.entryKey(value, context);
|
|
1657
|
+
if (this.filtering) {
|
|
1658
|
+
if (value === "\u001b") {
|
|
1659
|
+
frame.query = "";
|
|
1660
|
+
this.filtering = false;
|
|
1661
|
+
return { type: "handled" };
|
|
1662
|
+
}
|
|
1663
|
+
if (KEY_BACKSPACE.has(value)) {
|
|
1664
|
+
frame.query = frame.query.slice(0, -1);
|
|
1665
|
+
if (!frame.query) this.filtering = false;
|
|
1666
|
+
frame.index = 0;
|
|
1667
|
+
return { type: "handled" };
|
|
1668
|
+
}
|
|
1669
|
+
if (
|
|
1670
|
+
KEY_ENTER.has(value) ||
|
|
1671
|
+
value === "\u001b[B" ||
|
|
1672
|
+
value === "\u001b[A"
|
|
1673
|
+
) {
|
|
1674
|
+
this.filtering = false;
|
|
1675
|
+
return this.key(value, context);
|
|
1676
|
+
}
|
|
1677
|
+
if (value.length === 1 && value >= " ") {
|
|
1678
|
+
frame.query = (frame.query + value).slice(0, 40);
|
|
1679
|
+
frame.index = 0;
|
|
1680
|
+
return { type: "handled" };
|
|
1681
|
+
}
|
|
1682
|
+
return { type: "handled" };
|
|
1683
|
+
}
|
|
1684
|
+
const nodes = this.nodes(context);
|
|
1685
|
+
const node = this.selected(context);
|
|
1686
|
+
if (value === "\u001b") {
|
|
1687
|
+
if (frame.query) {
|
|
1688
|
+
frame.query = "";
|
|
1689
|
+
return { type: "handled" };
|
|
1690
|
+
}
|
|
1691
|
+
// In a list, Esc drops the hovered item and goes back; elsewhere it
|
|
1692
|
+
// reverts staged edits first, then goes back as usual.
|
|
1693
|
+
if (frame.hover && frame.hovering && context.audition) {
|
|
1694
|
+
this.stack.pop();
|
|
1695
|
+
return { type: "unhover", key: frame.hover };
|
|
1696
|
+
}
|
|
1697
|
+
if (context.audition?.dirty) return { type: "revert" };
|
|
1698
|
+
this.stack.pop();
|
|
1699
|
+
return this.stack.length ? { type: "handled" } : { type: "close" };
|
|
1700
|
+
}
|
|
1701
|
+
if (value === "/") {
|
|
1702
|
+
this.filtering = true;
|
|
1703
|
+
frame.query = "";
|
|
1704
|
+
return { type: "handled" };
|
|
1705
|
+
}
|
|
1706
|
+
if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
|
|
1707
|
+
const step = KEY_UP.has(value) ? -1 : 1;
|
|
1708
|
+
if (nodes.length)
|
|
1709
|
+
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
1710
|
+
return this.hovered(frame, context);
|
|
1711
|
+
}
|
|
1712
|
+
if (value === "\u001b[5~" || value === "\u001b[H") {
|
|
1713
|
+
frame.index = 0;
|
|
1714
|
+
return this.hovered(frame, context);
|
|
1715
|
+
}
|
|
1716
|
+
if (value === "\u001b[6~" || value === "\u001b[F") {
|
|
1717
|
+
frame.index = Math.max(0, nodes.length - 1);
|
|
1718
|
+
return this.hovered(frame, context);
|
|
1719
|
+
}
|
|
1720
|
+
// Space hears the focused track (it still switches a toggle row);
|
|
1721
|
+
// `a` and `c` are A/B and context while the window hosts an audition.
|
|
1722
|
+
const audition = context.audition ? auditionKey(value) : undefined;
|
|
1723
|
+
if (audition && !(audition === "loop" && node?.kind === "toggle"))
|
|
1724
|
+
return { type: "audition", key: audition };
|
|
1725
|
+
if (!node) return { type: "handled" };
|
|
1726
|
+
if (
|
|
1727
|
+
KEY_ENTER.has(value) &&
|
|
1728
|
+
context.audition?.dirty &&
|
|
1729
|
+
(node.kind === "number" ||
|
|
1730
|
+
node.kind === "toggle" ||
|
|
1731
|
+
node.kind === "point" ||
|
|
1732
|
+
node.kind === "info")
|
|
1733
|
+
)
|
|
1734
|
+
return { type: "keep" };
|
|
1735
|
+
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
1736
|
+
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
1737
|
+
return this.nudge(node, direction);
|
|
1738
|
+
}
|
|
1739
|
+
if (KEY_DELETE.has(value) && node.kind === "number") {
|
|
1740
|
+
const reset =
|
|
1741
|
+
node.reset ??
|
|
1742
|
+
(node.start !== undefined && node.value !== undefined
|
|
1743
|
+
? node.command(node.start)
|
|
1744
|
+
: undefined);
|
|
1745
|
+
return reset ? { type: "run", command: reset } : { type: "handled" };
|
|
1746
|
+
}
|
|
1747
|
+
if (KEY_DELETE.has(value) && node.kind === "point")
|
|
1748
|
+
return {
|
|
1749
|
+
type: "run",
|
|
1750
|
+
command: `automate ${node.lane} remove ${num(node.beat)}`,
|
|
1751
|
+
};
|
|
1752
|
+
if (
|
|
1753
|
+
/^[0-9.]$/.test(value) &&
|
|
1754
|
+
(node.kind === "number" || node.kind === "point")
|
|
1755
|
+
) {
|
|
1756
|
+
this.entry = { label: node.label, buffer: value };
|
|
1757
|
+
return { type: "handled" };
|
|
1758
|
+
}
|
|
1759
|
+
if (KEY_ENTER.has(value) || value === " ") {
|
|
1760
|
+
if (node.kind === "menu") {
|
|
1761
|
+
// Going back lands on the opened row with the filter cleared.
|
|
1762
|
+
if (frame.query) {
|
|
1763
|
+
frame.query = "";
|
|
1764
|
+
frame.index = Math.max(
|
|
1765
|
+
0,
|
|
1766
|
+
this.nodes(context).findIndex(
|
|
1767
|
+
(candidate) =>
|
|
1768
|
+
candidate.kind === "menu" && candidate.id === node.id,
|
|
1769
|
+
),
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
this.stack.push(childFrame(frame, node));
|
|
1773
|
+
return { type: "handled" };
|
|
1774
|
+
}
|
|
1775
|
+
if (node.kind === "toggle")
|
|
1776
|
+
return { type: "run", command: node.command(!node.value) };
|
|
1777
|
+
if (node.kind === "action")
|
|
1778
|
+
return frame.hover &&
|
|
1779
|
+
context.audition?.looping &&
|
|
1780
|
+
isStageable(node.command)
|
|
1781
|
+
? { type: "choose", command: node.command, key: frame.hover }
|
|
1782
|
+
: { type: "run", command: node.command };
|
|
1783
|
+
if (node.kind === "choice") {
|
|
1784
|
+
const current = node.options.indexOf(node.value);
|
|
1785
|
+
this.stack.push({
|
|
1786
|
+
title: node.label,
|
|
1787
|
+
index: Math.max(0, current),
|
|
1788
|
+
query: "",
|
|
1789
|
+
hover: node.label,
|
|
1790
|
+
build: (fresh) => {
|
|
1791
|
+
const parent = this.stack.at(-2);
|
|
1792
|
+
const live =
|
|
1793
|
+
parent
|
|
1794
|
+
?.build(fresh)
|
|
1795
|
+
.find(
|
|
1796
|
+
(candidate): candidate is typeof node =>
|
|
1797
|
+
candidate.kind === "choice" &&
|
|
1798
|
+
candidate.label === node.label,
|
|
1799
|
+
) ?? node;
|
|
1800
|
+
return live.options.map((option) => ({
|
|
1801
|
+
kind: "action",
|
|
1802
|
+
label: option === live.value ? `${option} ✓` : option,
|
|
1803
|
+
command: live.command(option),
|
|
1804
|
+
}));
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
return { type: "handled" };
|
|
1808
|
+
}
|
|
1809
|
+
if (
|
|
1810
|
+
node.kind === "entry" ||
|
|
1811
|
+
node.kind === "number" ||
|
|
1812
|
+
node.kind === "point"
|
|
1813
|
+
) {
|
|
1814
|
+
this.entry = { label: node.label, buffer: "" };
|
|
1815
|
+
return { type: "handled" };
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return { type: "handled" };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/** After a move: audition the item under the cursor in a list. */
|
|
1822
|
+
private hovered(frame: Frame, context: MenuContext): MenuResult {
|
|
1823
|
+
if (!frame.hover || !context.audition?.looping) return { type: "handled" };
|
|
1824
|
+
const node = this.selected(context);
|
|
1825
|
+
if (node?.kind !== "action" || !isStageable(node.command))
|
|
1826
|
+
return { type: "handled" };
|
|
1827
|
+
frame.hovering = true;
|
|
1828
|
+
return { type: "hover", command: node.command, key: frame.hover };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
1832
|
+
if (node.kind === "menu") {
|
|
1833
|
+
if (direction > 0) {
|
|
1834
|
+
this.stack.push(childFrame(this.stack.at(-1)!, node));
|
|
1835
|
+
return { type: "handled" };
|
|
1836
|
+
}
|
|
1837
|
+
if (this.stack.length > 1) this.stack.pop();
|
|
1838
|
+
return { type: "handled" };
|
|
1839
|
+
}
|
|
1840
|
+
if (node.kind === "number") {
|
|
1841
|
+
const next =
|
|
1842
|
+
node.value === undefined
|
|
1843
|
+
? (node.start ?? node.min)
|
|
1844
|
+
: clamp(node.step(node.value, direction), node.min, node.max);
|
|
1845
|
+
if (node.value !== undefined && Math.abs(next - node.value) < 1e-9)
|
|
1846
|
+
return { type: "handled" };
|
|
1847
|
+
return { type: "run", command: node.command(next) };
|
|
1848
|
+
}
|
|
1849
|
+
if (node.kind === "point") {
|
|
1850
|
+
const { min, max } = automationRange(node.lane);
|
|
1851
|
+
const next = clamp(laneStep(node.lane)(node.value, direction), min, max);
|
|
1852
|
+
if (Math.abs(next - node.value) < 1e-9) return { type: "handled" };
|
|
1853
|
+
return {
|
|
1854
|
+
type: "run",
|
|
1855
|
+
command: `automate ${node.lane} points ${num(node.beat)}:${laneFormat(node.lane, next)}`,
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
if (node.kind === "toggle") {
|
|
1859
|
+
const want = direction > 0;
|
|
1860
|
+
return want === node.value
|
|
1861
|
+
? { type: "handled" }
|
|
1862
|
+
: { type: "run", command: node.command(want) };
|
|
1863
|
+
}
|
|
1864
|
+
if (node.kind === "choice") {
|
|
1865
|
+
const at = node.options.indexOf(node.value);
|
|
1866
|
+
const next =
|
|
1867
|
+
node.options[clamp(at + direction, 0, node.options.length - 1)];
|
|
1868
|
+
return next === undefined || next === node.value
|
|
1869
|
+
? { type: "handled" }
|
|
1870
|
+
: { type: "run", command: node.command(next) };
|
|
1871
|
+
}
|
|
1872
|
+
if (direction < 0 && this.stack.length > 1) {
|
|
1873
|
+
const left = this.stack.pop();
|
|
1874
|
+
if (left?.hover && left.hovering)
|
|
1875
|
+
return { type: "unhover", key: left.hover };
|
|
1876
|
+
}
|
|
1877
|
+
return { type: "handled" };
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
private entryKey(value: string, context: MenuContext): MenuResult {
|
|
1881
|
+
const entry = this.entry!;
|
|
1882
|
+
if (value === "\u001b") {
|
|
1883
|
+
this.entry = undefined;
|
|
1884
|
+
return { type: "handled" };
|
|
1885
|
+
}
|
|
1886
|
+
if (KEY_BACKSPACE.has(value)) {
|
|
1887
|
+
entry.buffer = entry.buffer.slice(0, -1);
|
|
1888
|
+
return { type: "handled" };
|
|
1889
|
+
}
|
|
1890
|
+
if (KEY_ENTER.has(value)) {
|
|
1891
|
+
const node = this.selected(context);
|
|
1892
|
+
this.entry = undefined;
|
|
1893
|
+
const command = node ? entryCommand(node, entry.buffer) : undefined;
|
|
1894
|
+
if (!command) return { type: "handled" };
|
|
1895
|
+
// A committed value clears the filter so its result (a new automation
|
|
1896
|
+
// point, a renamed row) is in view, keeping the cursor on the row.
|
|
1897
|
+
const frame = this.stack.at(-1)!;
|
|
1898
|
+
if (frame.query && node) {
|
|
1899
|
+
frame.query = "";
|
|
1900
|
+
const index = this.nodes(context).findIndex(
|
|
1901
|
+
(candidate) => candidate.label === node.label,
|
|
1902
|
+
);
|
|
1903
|
+
frame.index = Math.max(0, index);
|
|
1904
|
+
}
|
|
1905
|
+
return { type: "run", command };
|
|
1906
|
+
}
|
|
1907
|
+
if (value.length >= 1 && !value.startsWith("\u001b") && value >= " ") {
|
|
1908
|
+
entry.buffer = (entry.buffer + value).slice(0, 200);
|
|
1909
|
+
}
|
|
1910
|
+
return { type: "handled" };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/** The picker the TUI draws for the current level. */
|
|
1914
|
+
view(context: MenuContext): MenuView {
|
|
1915
|
+
const frame = this.stack.at(-1);
|
|
1916
|
+
const nodes = this.nodes(context);
|
|
1917
|
+
const index = frame
|
|
1918
|
+
? clamp(frame.index, 0, Math.max(0, nodes.length - 1))
|
|
1919
|
+
: 0;
|
|
1920
|
+
const crumbs = this.stack.map((level) => level.title).join(" › ");
|
|
1921
|
+
const selected = nodes[index];
|
|
1922
|
+
let title = crumbs;
|
|
1923
|
+
if (frame?.query || this.filtering) title += ` · /${frame?.query ?? ""}`;
|
|
1924
|
+
if (this.entry)
|
|
1925
|
+
title += ` · ${this.entry.label}: ${this.entry.buffer || placeholderFor(selected)}▏`;
|
|
1926
|
+
const audition = context.audition;
|
|
1927
|
+
if (audition?.dirty) title = `● ${title}`;
|
|
1928
|
+
if (audition?.status && !this.entry) title += ` · ${audition.status}`;
|
|
1929
|
+
// Changed rows show `staged ← committed` (Elektron's compare, inline).
|
|
1930
|
+
const before = new Map<string, string>();
|
|
1931
|
+
if (audition?.dirty && frame && !frame.query)
|
|
1932
|
+
for (const node of frame.build({
|
|
1933
|
+
...context,
|
|
1934
|
+
score: audition.committed,
|
|
1935
|
+
}))
|
|
1936
|
+
before.set(node.label, valueText(node));
|
|
1937
|
+
const items = nodes.map((node, at) => {
|
|
1938
|
+
const now = valueText(node);
|
|
1939
|
+
const was = node.kind === "menu" ? undefined : before.get(node.label);
|
|
1940
|
+
const shown = was !== undefined && was !== now ? `${now} ← ${was}` : now;
|
|
1941
|
+
return {
|
|
1942
|
+
label: `${node.label.padEnd(LABEL_WIDTH)} ${shown}`.trimEnd(),
|
|
1943
|
+
value: String(at),
|
|
1944
|
+
};
|
|
1945
|
+
});
|
|
1946
|
+
const command = selected ? commandText(selected) : undefined;
|
|
1947
|
+
const note = [
|
|
1948
|
+
selected ? describe(selected) : "",
|
|
1949
|
+
selected?.kind === "number" && command
|
|
1950
|
+
? rowSketch(selected, command, nodes)
|
|
1951
|
+
: "",
|
|
1952
|
+
command ? `› ${command}` : "",
|
|
1953
|
+
]
|
|
1954
|
+
.filter(Boolean)
|
|
1955
|
+
.join(" ");
|
|
1956
|
+
const hint = this.entry
|
|
1957
|
+
? HINTS.typing
|
|
1958
|
+
: this.filtering
|
|
1959
|
+
? HINTS.filtering
|
|
1960
|
+
: audition?.looping && frame?.hover && selected?.kind === "action"
|
|
1961
|
+
? HINTS.hover
|
|
1962
|
+
: audition && (audition.looping || audition.dirty)
|
|
1963
|
+
? audition.hint
|
|
1964
|
+
: selected?.kind === "point"
|
|
1965
|
+
? HINTS.point
|
|
1966
|
+
: selected?.kind === "number" ||
|
|
1967
|
+
selected?.kind === "toggle" ||
|
|
1968
|
+
selected?.kind === "choice"
|
|
1969
|
+
? HINTS.value
|
|
1970
|
+
: selected?.kind === "action"
|
|
1971
|
+
? HINTS.action
|
|
1972
|
+
: HINTS.menu;
|
|
1973
|
+
return { title, items, index, hint, note };
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** The focused row's one-line description. */
|
|
1978
|
+
function describe(node: MenuNode): string {
|
|
1979
|
+
if (node.help) return node.help;
|
|
1980
|
+
switch (node.kind) {
|
|
1981
|
+
case "number":
|
|
1982
|
+
return node.reset || node.start !== undefined
|
|
1983
|
+
? "←→ adjust · type a number · x resets"
|
|
1984
|
+
: "←→ adjust · type a number";
|
|
1985
|
+
case "toggle":
|
|
1986
|
+
return "space or enter switches it";
|
|
1987
|
+
case "choice":
|
|
1988
|
+
return `${node.options.length} choices · enter lists them`;
|
|
1989
|
+
case "point":
|
|
1990
|
+
return "←→ change · type beat:value · x deletes";
|
|
1991
|
+
case "entry":
|
|
1992
|
+
return "enter, then type";
|
|
1993
|
+
default:
|
|
1994
|
+
return "";
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function placeholderFor(node: MenuNode | undefined): string {
|
|
1999
|
+
if (!node) return "";
|
|
2000
|
+
if (node.kind === "entry") return node.placeholder;
|
|
2001
|
+
if (node.kind === "number")
|
|
2002
|
+
return `${node.format(node.min)}…${node.format(node.max)}`;
|
|
2003
|
+
if (node.kind === "point") return "value";
|
|
2004
|
+
return "";
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function entryCommand(node: MenuNode, text: string): string | undefined {
|
|
2008
|
+
const trimmed = text.trim();
|
|
2009
|
+
if (node.kind === "entry") return node.command(trimmed);
|
|
2010
|
+
if (node.kind === "number") {
|
|
2011
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) return undefined;
|
|
2012
|
+
const value = Number(trimmed);
|
|
2013
|
+
if (value < node.min || value > node.max) return undefined;
|
|
2014
|
+
return node.command(value);
|
|
2015
|
+
}
|
|
2016
|
+
if (node.kind === "point") {
|
|
2017
|
+
// `0.8` sets the value; `6:0.8` moves to beat 6.
|
|
2018
|
+
const { min, max } = automationRange(node.lane);
|
|
2019
|
+
const match = trimmed.match(/^(?:(\d+(?:\.\d+)?):)?(-?\d+(?:\.\d+)?)$/);
|
|
2020
|
+
if (!match) return undefined;
|
|
2021
|
+
const value = Number(match[2]);
|
|
2022
|
+
if (value < min || value > max) return undefined;
|
|
2023
|
+
if (match[1] !== undefined && Number(match[1]) !== node.beat)
|
|
2024
|
+
return `automate ${node.lane} points ${match[1]}:${match[2]}`;
|
|
2025
|
+
return `automate ${node.lane} points ${num(node.beat)}:${match[2]}`;
|
|
2026
|
+
}
|
|
2027
|
+
return undefined;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function valueText(node: MenuNode): string {
|
|
2031
|
+
switch (node.kind) {
|
|
2032
|
+
case "menu":
|
|
2033
|
+
return node.detail;
|
|
2034
|
+
case "number":
|
|
2035
|
+
return node.value === undefined
|
|
2036
|
+
? (node.off ?? "—")
|
|
2037
|
+
: node.format(node.value);
|
|
2038
|
+
case "toggle":
|
|
2039
|
+
return node.value ? "on" : "off";
|
|
2040
|
+
case "choice":
|
|
2041
|
+
return node.value;
|
|
2042
|
+
case "entry":
|
|
2043
|
+
return node.value;
|
|
2044
|
+
case "point":
|
|
2045
|
+
return laneFormat(node.lane, node.value);
|
|
2046
|
+
case "info":
|
|
2047
|
+
return node.value;
|
|
2048
|
+
case "action":
|
|
2049
|
+
return "";
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/** The command a row stands for, shown so the menu teaches commands. */
|
|
2054
|
+
/** A filter curve, envelope or table position for a focused number row. */
|
|
2055
|
+
function rowSketch(
|
|
2056
|
+
node: Extract<MenuNode, { kind: "number" }>,
|
|
2057
|
+
command: string,
|
|
2058
|
+
nodes: readonly MenuNode[],
|
|
2059
|
+
): string {
|
|
2060
|
+
const value = node.value ?? node.start ?? node.min;
|
|
2061
|
+
return (
|
|
2062
|
+
sketchFor(
|
|
2063
|
+
command,
|
|
2064
|
+
value,
|
|
2065
|
+
(param) => {
|
|
2066
|
+
for (const sibling of nodes) {
|
|
2067
|
+
if (sibling.kind !== "number") continue;
|
|
2068
|
+
const text = commandText(sibling);
|
|
2069
|
+
if (text && commandParam(text) === param)
|
|
2070
|
+
return sibling.value ?? sibling.start;
|
|
2071
|
+
}
|
|
2072
|
+
return undefined;
|
|
2073
|
+
},
|
|
2074
|
+
filterTypeOf(nodes),
|
|
2075
|
+
) ?? ""
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function filterTypeOf(nodes: readonly MenuNode[]): string | undefined {
|
|
2080
|
+
const row = nodes.find(
|
|
2081
|
+
(node) => node.kind === "choice" && node.label === "type",
|
|
2082
|
+
);
|
|
2083
|
+
return row?.kind === "choice" && typeof row.value === "string"
|
|
2084
|
+
? row.value
|
|
2085
|
+
: undefined;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function commandText(node: MenuNode): string | undefined {
|
|
2089
|
+
switch (node.kind) {
|
|
2090
|
+
case "number":
|
|
2091
|
+
return node.command(node.value ?? node.start ?? node.min);
|
|
2092
|
+
case "toggle":
|
|
2093
|
+
return node.command(!node.value);
|
|
2094
|
+
case "choice":
|
|
2095
|
+
return node.command(node.value);
|
|
2096
|
+
case "entry":
|
|
2097
|
+
return node.example;
|
|
2098
|
+
case "action":
|
|
2099
|
+
return node.command;
|
|
2100
|
+
case "point":
|
|
2101
|
+
return `automate ${node.lane} points ${num(node.beat)}:${laneFormat(node.lane, node.value)}`;
|
|
2102
|
+
default:
|
|
2103
|
+
return undefined;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function nodeText(node: MenuNode): string {
|
|
2108
|
+
return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** Root sections, for `/menu <section>`. */
|
|
2112
|
+
export const MENU_SECTIONS = [
|
|
2113
|
+
"sound",
|
|
2114
|
+
"effects",
|
|
2115
|
+
"rhythm",
|
|
2116
|
+
"chords",
|
|
2117
|
+
"mix",
|
|
2118
|
+
"project",
|
|
2119
|
+
// Older names, still accepted.
|
|
2120
|
+
"parameters",
|
|
2121
|
+
"sounds",
|
|
2122
|
+
"track",
|
|
2123
|
+
"automation",
|
|
2124
|
+
"transport",
|
|
2125
|
+
] as const;
|