@hraness/dawg 0.3.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 +122 -0
- package/DAWG.md +605 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -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 +27 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- 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 +280 -278
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1119 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +1286 -244
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/src/tui/menu.ts
CHANGED
|
@@ -8,15 +8,84 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Rendering reuses the TUI's picker overlay: `view()` returns a picker.
|
|
10
10
|
*/
|
|
11
|
+
import { commandParam, sketchFor } from "./sketch.ts";
|
|
12
|
+
import { HINTS } from "../../tui/grammar.ts";
|
|
13
|
+
import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
|
|
11
14
|
import {
|
|
12
|
-
|
|
15
|
+
AUTOMATION_PARAMETERS,
|
|
16
|
+
automationPoints,
|
|
17
|
+
automationRange,
|
|
18
|
+
REVERB_IR_BUILTINS,
|
|
19
|
+
SAMPLE_UNITS,
|
|
13
20
|
SCORE_LIMITS,
|
|
21
|
+
isTrackAutomationParameter,
|
|
22
|
+
WARP_MODES,
|
|
23
|
+
WAVETABLE_PARAMS,
|
|
24
|
+
isWavetableInstrument,
|
|
25
|
+
wavetableOf,
|
|
26
|
+
type WavetableParam,
|
|
14
27
|
type AutomationParameter,
|
|
15
28
|
type Track,
|
|
29
|
+
type TrackAutomationParameter,
|
|
16
30
|
type TrackScore,
|
|
17
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";
|
|
18
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";
|
|
19
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";
|
|
20
89
|
|
|
21
90
|
/** What the menu needs to know beyond the score. */
|
|
22
91
|
export type MenuContext = Readonly<{
|
|
@@ -27,6 +96,29 @@ export type MenuContext = Readonly<{
|
|
|
27
96
|
grids: readonly string[];
|
|
28
97
|
clickOn: boolean;
|
|
29
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;
|
|
30
122
|
}>;
|
|
31
123
|
|
|
32
124
|
type NumberField = Readonly<{
|
|
@@ -44,9 +136,11 @@ type NumberField = Readonly<{
|
|
|
44
136
|
off?: string;
|
|
45
137
|
/** Starting value for a nudge while off. */
|
|
46
138
|
start?: number;
|
|
139
|
+
/** The command `x` runs to put the value back to its default. */
|
|
140
|
+
reset?: string;
|
|
47
141
|
}>;
|
|
48
142
|
|
|
49
|
-
export type MenuNode =
|
|
143
|
+
export type MenuNode = (
|
|
50
144
|
| Readonly<{
|
|
51
145
|
kind: "menu";
|
|
52
146
|
id: string;
|
|
@@ -87,13 +181,33 @@ export type MenuNode =
|
|
|
87
181
|
beat: number;
|
|
88
182
|
value: number;
|
|
89
183
|
}>
|
|
90
|
-
| Readonly<{ kind: "info"; label: string; value: string }
|
|
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
|
+
}>;
|
|
91
190
|
|
|
92
191
|
/** What a key did; `run` carries the command to execute. */
|
|
93
192
|
export type MenuResult =
|
|
94
193
|
| { type: "handled" }
|
|
95
194
|
| { type: "close" }
|
|
96
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 }
|
|
97
211
|
| { type: "pass" };
|
|
98
212
|
|
|
99
213
|
export type MenuView = Readonly<{
|
|
@@ -101,6 +215,8 @@ export type MenuView = Readonly<{
|
|
|
101
215
|
items: PickerItem[];
|
|
102
216
|
index: number;
|
|
103
217
|
hint: string;
|
|
218
|
+
/** What the focused row does, and the prompt command it stands for. */
|
|
219
|
+
note: string;
|
|
104
220
|
}>;
|
|
105
221
|
|
|
106
222
|
// ── formatting and steps ──────────────────────────────────────────────
|
|
@@ -137,8 +253,8 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
137
253
|
return Math.min(max, Math.max(min, value));
|
|
138
254
|
}
|
|
139
255
|
|
|
140
|
-
const
|
|
141
|
-
Record<
|
|
256
|
+
const TRACK_LANE_STEP: Readonly<
|
|
257
|
+
Record<TrackAutomationParameter, (value: number, direction: 1 | -1) => number>
|
|
142
258
|
> = {
|
|
143
259
|
volume: linear(0.05, 0, SCORE_LIMITS.maxVolume),
|
|
144
260
|
pan: linear(0.1, -1, 1),
|
|
@@ -146,20 +262,65 @@ const LANE_STEP: Readonly<
|
|
|
146
262
|
resonance: linear(0.05, 0, SCORE_LIMITS.maxFilterResonance),
|
|
147
263
|
"delay-feedback": linear(0.05, 0, SCORE_LIMITS.maxDelayFeedback),
|
|
148
264
|
"delay-mix": linear(0.05, 0, SCORE_LIMITS.maxDelayMix),
|
|
265
|
+
wt: linear(0.05, 0, 1),
|
|
149
266
|
};
|
|
150
267
|
|
|
151
|
-
const
|
|
268
|
+
const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
152
269
|
volume: "volume",
|
|
153
270
|
pan: "pan",
|
|
154
271
|
filter: "filter cutoff (Hz)",
|
|
155
272
|
resonance: "filter resonance",
|
|
156
273
|
"delay-feedback": "delay feedback",
|
|
157
274
|
"delay-mix": "delay mix",
|
|
275
|
+
wt: "wavetable position",
|
|
158
276
|
};
|
|
159
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
|
+
|
|
160
318
|
/** `volume 0.8`, `pan -0.3`: the command that sets a lane's static value. */
|
|
161
319
|
function laneFormat(lane: AutomationParameter, value: number): string {
|
|
162
|
-
|
|
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);
|
|
163
324
|
}
|
|
164
325
|
|
|
165
326
|
// ── the tree ──────────────────────────────────────────────────────────
|
|
@@ -171,60 +332,275 @@ function focused(context: MenuContext): Track | undefined {
|
|
|
171
332
|
export function rootNodes(context: MenuContext): MenuNode[] {
|
|
172
333
|
const track = focused(context);
|
|
173
334
|
const name = track?.name ?? context.trackId;
|
|
174
|
-
const effects =
|
|
175
|
-
|
|
176
|
-
track
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const automated = (Object.keys(AUTOMATION_LANES) as AutomationParameter[])
|
|
180
|
-
.filter((lane) => (track?.[AUTOMATION_LANES[lane].field]?.length ?? 0) > 0)
|
|
181
|
-
.join(" ");
|
|
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;
|
|
182
340
|
return [
|
|
183
341
|
{
|
|
184
342
|
kind: "menu",
|
|
185
|
-
id: "
|
|
186
|
-
label: "
|
|
343
|
+
id: "sound",
|
|
344
|
+
label: "Sound",
|
|
187
345
|
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
{
|
|
191
|
-
kind: "menu",
|
|
192
|
-
id: "parameters",
|
|
193
|
-
label: "Parameters",
|
|
194
|
-
detail: `${track?.instrument ?? "?"}`,
|
|
195
|
-
build: parameterNodes,
|
|
346
|
+
help: "the focused track's instrument and its voice",
|
|
347
|
+
build: soundSectionNodes,
|
|
196
348
|
},
|
|
197
349
|
{
|
|
198
350
|
kind: "menu",
|
|
199
351
|
id: "effects",
|
|
200
352
|
label: "Effects",
|
|
201
|
-
detail: effects.length ? effects.join(" · ") : "none",
|
|
353
|
+
detail: effects.length ? effects.join(" · ") : "none on",
|
|
354
|
+
help: "filter, delay, reverb and more on the focused track",
|
|
202
355
|
build: effectNodes,
|
|
203
356
|
},
|
|
204
357
|
{
|
|
205
358
|
kind: "menu",
|
|
206
|
-
id: "
|
|
207
|
-
label: "
|
|
208
|
-
detail:
|
|
209
|
-
|
|
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,
|
|
210
372
|
},
|
|
211
373
|
{
|
|
212
374
|
kind: "menu",
|
|
213
375
|
id: "mix",
|
|
214
|
-
label: "Mix",
|
|
215
|
-
detail: `${
|
|
216
|
-
|
|
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,
|
|
217
380
|
},
|
|
218
381
|
{
|
|
219
382
|
kind: "menu",
|
|
220
|
-
id: "
|
|
221
|
-
label: "
|
|
222
|
-
detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars
|
|
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",
|
|
223
387
|
build: transportNodes,
|
|
224
388
|
},
|
|
225
389
|
];
|
|
226
390
|
}
|
|
227
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
|
+
|
|
228
604
|
function trackNodes(context: MenuContext): MenuNode[] {
|
|
229
605
|
const track = focused(context);
|
|
230
606
|
if (!track)
|
|
@@ -238,19 +614,21 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
238
614
|
command: (text) =>
|
|
239
615
|
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
240
616
|
example: `track name ${track.name}`,
|
|
617
|
+
help: "rename the focused track",
|
|
241
618
|
},
|
|
242
|
-
instrumentNode(track),
|
|
243
619
|
{
|
|
244
620
|
kind: "toggle",
|
|
245
621
|
label: "mute",
|
|
246
622
|
value: track.muted,
|
|
247
623
|
command: (on) => (on ? "mute" : "unmute"),
|
|
624
|
+
help: "silence this track",
|
|
248
625
|
},
|
|
249
626
|
{
|
|
250
627
|
kind: "toggle",
|
|
251
628
|
label: "solo",
|
|
252
629
|
value: track.solo === true,
|
|
253
630
|
command: (on) => (on ? "solo" : "unsolo"),
|
|
631
|
+
help: "hear only soloed tracks",
|
|
254
632
|
},
|
|
255
633
|
volumeNode(track),
|
|
256
634
|
panNode(track),
|
|
@@ -266,6 +644,7 @@ function instrumentNode(track: Track): MenuNode {
|
|
|
266
644
|
value: track.instrument,
|
|
267
645
|
options,
|
|
268
646
|
command: (option) => `instrument ${option}`,
|
|
647
|
+
help: "the sound source; drums use kit",
|
|
269
648
|
};
|
|
270
649
|
}
|
|
271
650
|
|
|
@@ -276,9 +655,12 @@ function volumeNode(track: Track): MenuNode {
|
|
|
276
655
|
value: track.volume,
|
|
277
656
|
min: 0,
|
|
278
657
|
max: SCORE_LIMITS.maxVolume,
|
|
279
|
-
step:
|
|
280
|
-
format:
|
|
658
|
+
step: TRACK_LANE_STEP.volume,
|
|
659
|
+
format: (value) =>
|
|
660
|
+
`${num(value)} · ${value > 0 ? `${(20 * Math.log10(value)).toFixed(1)} dB` : "silent"}`,
|
|
281
661
|
command: (value) => `volume ${num(value)}`,
|
|
662
|
+
reset: "volume 1",
|
|
663
|
+
help: "track gain; 1 is unity (0 dB) · x resets",
|
|
282
664
|
};
|
|
283
665
|
}
|
|
284
666
|
|
|
@@ -289,9 +671,14 @@ function panNode(track: Track): MenuNode {
|
|
|
289
671
|
value: track.pan,
|
|
290
672
|
min: -1,
|
|
291
673
|
max: 1,
|
|
292
|
-
step:
|
|
293
|
-
format:
|
|
674
|
+
step: TRACK_LANE_STEP.pan,
|
|
675
|
+
format: (value) =>
|
|
676
|
+
value === 0
|
|
677
|
+
? "center"
|
|
678
|
+
: `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
|
|
294
679
|
command: (value) => `pan ${num(value)}`,
|
|
680
|
+
reset: "pan 0",
|
|
681
|
+
help: "-1 left … 1 right · x centres",
|
|
295
682
|
};
|
|
296
683
|
}
|
|
297
684
|
|
|
@@ -299,6 +686,9 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
299
686
|
const track = focused(context);
|
|
300
687
|
if (!track) return [];
|
|
301
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));
|
|
302
692
|
if (track.sampler) {
|
|
303
693
|
nodes.push({
|
|
304
694
|
kind: "info",
|
|
@@ -312,9 +702,12 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
312
702
|
ref.speed !== undefined ? `speed ${num(ref.speed)}` : "",
|
|
313
703
|
].filter(Boolean);
|
|
314
704
|
nodes.push({
|
|
315
|
-
kind: "
|
|
705
|
+
kind: "menu",
|
|
706
|
+
id: `sample:${voice}`,
|
|
316
707
|
label: voice,
|
|
317
|
-
|
|
708
|
+
detail: [ref.src.split("/").at(-1), ...extras].join(" · "),
|
|
709
|
+
help: "this voice's sample controls (Strudel names)",
|
|
710
|
+
build: (context) => sampleVoiceNodes(context, voice),
|
|
318
711
|
});
|
|
319
712
|
}
|
|
320
713
|
nodes.push({
|
|
@@ -322,188 +715,546 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
322
715
|
label: "add a voice",
|
|
323
716
|
value: "/sample <path> [as <voice>]",
|
|
324
717
|
});
|
|
325
|
-
} else {
|
|
718
|
+
} else if (!isDrumInstrument(track.instrument)) {
|
|
719
|
+
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
326
720
|
nodes.push({
|
|
327
|
-
kind: "
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
331
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
|
+
});
|
|
332
735
|
}
|
|
333
736
|
return nodes;
|
|
334
737
|
}
|
|
335
738
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(
|
|
343
798
|
{
|
|
344
|
-
kind: "
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
max: SCORE_LIMITS.maxFilterCutoff,
|
|
365
|
-
step: cutoffStep,
|
|
366
|
-
format: (value) => `${Math.round(value)}`,
|
|
367
|
-
command: (value) =>
|
|
368
|
-
`filter ${Math.round(value)} ${num(filter?.resonance ?? 0)}`,
|
|
369
|
-
},
|
|
370
|
-
{
|
|
371
|
-
kind: "number",
|
|
372
|
-
label: "resonance",
|
|
373
|
-
value: filter?.resonance,
|
|
374
|
-
start: 0.2,
|
|
375
|
-
off: "off",
|
|
376
|
-
min: 0,
|
|
377
|
-
max: SCORE_LIMITS.maxFilterResonance,
|
|
378
|
-
step: LANE_STEP.resonance,
|
|
379
|
-
format: num,
|
|
380
|
-
command: (value) =>
|
|
381
|
-
`filter ${Math.round(filter?.cutoff ?? 2000)} ${num(value)}`,
|
|
382
|
-
},
|
|
383
|
-
],
|
|
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,
|
|
384
819
|
},
|
|
385
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 {
|
|
386
918
|
kind: "menu",
|
|
387
|
-
id:
|
|
388
|
-
label:
|
|
389
|
-
detail:
|
|
390
|
-
?
|
|
391
|
-
: "
|
|
392
|
-
build: () => {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const mix = delay?.mix ?? 0.35;
|
|
396
|
-
return [
|
|
397
|
-
{
|
|
398
|
-
kind: "toggle",
|
|
399
|
-
label: "on",
|
|
400
|
-
value: delay !== undefined,
|
|
401
|
-
command: (on) => (on ? "delay 0.5 0.3 0.35" : "delay off"),
|
|
402
|
-
},
|
|
403
|
-
{
|
|
404
|
-
kind: "number",
|
|
405
|
-
label: "time beats",
|
|
406
|
-
value: delay?.beats,
|
|
407
|
-
start: 0.5,
|
|
408
|
-
off: "off",
|
|
409
|
-
min: SCORE_LIMITS.minDelayBeats,
|
|
410
|
-
max: SCORE_LIMITS.maxDelayBeats,
|
|
411
|
-
step: delayStep,
|
|
412
|
-
format: num,
|
|
413
|
-
command: (value) =>
|
|
414
|
-
`delay ${num(value)} ${num(feedback)} ${num(mix)}`,
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
kind: "number",
|
|
418
|
-
label: "feedback",
|
|
419
|
-
value: delay?.feedback,
|
|
420
|
-
start: 0.3,
|
|
421
|
-
off: "off",
|
|
422
|
-
min: 0,
|
|
423
|
-
max: SCORE_LIMITS.maxDelayFeedback,
|
|
424
|
-
step: LANE_STEP["delay-feedback"],
|
|
425
|
-
format: num,
|
|
426
|
-
command: (value) => `delay ${num(beats)} ${num(value)} ${num(mix)}`,
|
|
427
|
-
},
|
|
428
|
-
{
|
|
429
|
-
kind: "number",
|
|
430
|
-
label: "mix",
|
|
431
|
-
value: delay?.mix,
|
|
432
|
-
start: 0.35,
|
|
433
|
-
off: "off",
|
|
434
|
-
min: 0,
|
|
435
|
-
max: SCORE_LIMITS.maxDelayMix,
|
|
436
|
-
step: LANE_STEP["delay-mix"],
|
|
437
|
-
format: num,
|
|
438
|
-
command: (value) =>
|
|
439
|
-
`delay ${num(beats)} ${num(feedback)} ${num(value)}`,
|
|
440
|
-
},
|
|
441
|
-
];
|
|
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) : [];
|
|
442
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,
|
|
443
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),
|
|
444
1064
|
{
|
|
445
1065
|
kind: "menu",
|
|
446
|
-
id: "
|
|
447
|
-
label: "
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
label: "on",
|
|
455
|
-
value: reverb !== undefined,
|
|
456
|
-
command: (on) => (on ? "reverb 0.3 0.5" : "reverb off"),
|
|
457
|
-
},
|
|
458
|
-
{
|
|
459
|
-
kind: "number",
|
|
460
|
-
label: "mix",
|
|
461
|
-
value: reverb?.mix,
|
|
462
|
-
start: 0.3,
|
|
463
|
-
off: "off",
|
|
464
|
-
min: 0,
|
|
465
|
-
max: SCORE_LIMITS.maxReverbMix,
|
|
466
|
-
step: linear(0.05, 0, SCORE_LIMITS.maxReverbMix),
|
|
467
|
-
format: num,
|
|
468
|
-
command: (value) =>
|
|
469
|
-
`reverb ${num(value)} ${num(reverb?.size ?? 0.5)}`,
|
|
470
|
-
},
|
|
471
|
-
{
|
|
472
|
-
kind: "number",
|
|
473
|
-
label: "size",
|
|
474
|
-
value: reverb?.size,
|
|
475
|
-
start: 0.5,
|
|
476
|
-
off: "off",
|
|
477
|
-
min: SCORE_LIMITS.minReverbSize,
|
|
478
|
-
max: SCORE_LIMITS.maxReverbSize,
|
|
479
|
-
step: linear(
|
|
480
|
-
0.05,
|
|
481
|
-
SCORE_LIMITS.minReverbSize,
|
|
482
|
-
SCORE_LIMITS.maxReverbSize,
|
|
483
|
-
),
|
|
484
|
-
format: num,
|
|
485
|
-
command: (value) => `reverb ${num(reverb?.mix ?? 0.3)} ${num(value)}`,
|
|
486
|
-
},
|
|
487
|
-
],
|
|
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),
|
|
488
1074
|
},
|
|
489
1075
|
];
|
|
490
1076
|
}
|
|
491
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
|
+
|
|
492
1220
|
function automationNodes(context: MenuContext): MenuNode[] {
|
|
493
1221
|
const track = focused(context);
|
|
494
1222
|
if (!track) return [];
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
);
|
|
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
|
+
};
|
|
507
1258
|
}
|
|
508
1259
|
|
|
509
1260
|
/** `4:0.5 8:1` → `automate <lane> points 4:0.5 8:1`, when every pair parses. */
|
|
@@ -518,7 +1269,7 @@ function pointsCommand(
|
|
|
518
1269
|
.filter(Boolean);
|
|
519
1270
|
if (pairs.length === 0 || (exactly !== undefined && pairs.length !== exactly))
|
|
520
1271
|
return undefined;
|
|
521
|
-
const { min, max } =
|
|
1272
|
+
const { min, max } = automationRange(lane);
|
|
522
1273
|
for (const pair of pairs) {
|
|
523
1274
|
const match = pair.match(/^(\d+(?:\.\d+)?):(-?\d+(?:\.\d+)?)$/);
|
|
524
1275
|
if (!match) return undefined;
|
|
@@ -534,9 +1285,9 @@ function laneNodes(
|
|
|
534
1285
|
): MenuNode[] {
|
|
535
1286
|
const track = focused(context);
|
|
536
1287
|
if (!track) return [];
|
|
537
|
-
const { min, max
|
|
1288
|
+
const { min, max } = automationRange(lane);
|
|
538
1289
|
const range = `${laneFormat(lane, min)}…${laneFormat(lane, max)}`;
|
|
539
|
-
const points = track
|
|
1290
|
+
const points = automationPoints(track, lane);
|
|
540
1291
|
const nodes: MenuNode[] = [
|
|
541
1292
|
{
|
|
542
1293
|
kind: "entry",
|
|
@@ -592,6 +1343,134 @@ function mixNodes(context: MenuContext): MenuNode[] {
|
|
|
592
1343
|
});
|
|
593
1344
|
}
|
|
594
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
|
+
|
|
595
1474
|
function transportNodes(context: MenuContext): MenuNode[] {
|
|
596
1475
|
const score = context.score;
|
|
597
1476
|
return [
|
|
@@ -599,20 +1478,23 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
599
1478
|
kind: "action",
|
|
600
1479
|
label: context.playing ? "pause" : "play",
|
|
601
1480
|
command: context.playing ? "pause" : "play",
|
|
1481
|
+
help: "start or stop the loop (space on an empty prompt)",
|
|
602
1482
|
},
|
|
603
1483
|
{
|
|
604
1484
|
kind: "number",
|
|
605
|
-
label: "tempo
|
|
1485
|
+
label: "tempo",
|
|
1486
|
+
help: "beats per minute",
|
|
606
1487
|
value: score.tempoBpm,
|
|
607
1488
|
min: SCORE_LIMITS.minTempoBpm,
|
|
608
1489
|
max: SCORE_LIMITS.maxTempoBpm,
|
|
609
1490
|
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
610
|
-
format: num
|
|
1491
|
+
format: (value) => `${num(value)} BPM`,
|
|
611
1492
|
command: (value) => `tempo ${num(value)}`,
|
|
612
1493
|
},
|
|
613
1494
|
{
|
|
614
1495
|
kind: "number",
|
|
615
1496
|
label: "beats per bar",
|
|
1497
|
+
help: "the meter",
|
|
616
1498
|
value: score.beatsPerBar,
|
|
617
1499
|
min: 1,
|
|
618
1500
|
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
@@ -622,17 +1504,19 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
622
1504
|
},
|
|
623
1505
|
{
|
|
624
1506
|
kind: "number",
|
|
625
|
-
label: "loop
|
|
1507
|
+
label: "loop length",
|
|
1508
|
+
help: "how long the loop is",
|
|
626
1509
|
value: score.bars,
|
|
627
1510
|
min: 1,
|
|
628
1511
|
max: SCORE_LIMITS.maxBars,
|
|
629
1512
|
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
630
|
-
format: num
|
|
1513
|
+
format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
|
|
631
1514
|
command: (value) => `bars ${Math.round(value)}`,
|
|
632
1515
|
},
|
|
633
1516
|
{
|
|
634
1517
|
kind: "choice",
|
|
635
1518
|
label: "grid",
|
|
1519
|
+
help: "the step play-mode recording and the euclid editor snap to",
|
|
636
1520
|
value: context.grid,
|
|
637
1521
|
options: context.grids,
|
|
638
1522
|
command: (option) => `/grid ${option}`,
|
|
@@ -640,12 +1524,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
640
1524
|
{
|
|
641
1525
|
kind: "toggle",
|
|
642
1526
|
label: "click",
|
|
1527
|
+
help: "metronome while playing and recording",
|
|
643
1528
|
value: context.clickOn,
|
|
644
1529
|
command: (on) => `/click ${on ? "on" : "off"}`,
|
|
645
1530
|
},
|
|
646
1531
|
{
|
|
647
1532
|
kind: "choice",
|
|
648
1533
|
label: "count-in bars",
|
|
1534
|
+
help: "bars of click before recording starts",
|
|
649
1535
|
value: String(context.countInBars),
|
|
650
1536
|
options: ["0", "1", "2"],
|
|
651
1537
|
command: (option) => `/count-in ${option}`,
|
|
@@ -660,6 +1546,12 @@ type Frame = {
|
|
|
660
1546
|
build: (context: MenuContext) => MenuNode[];
|
|
661
1547
|
index: number;
|
|
662
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;
|
|
663
1555
|
};
|
|
664
1556
|
|
|
665
1557
|
const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
|
|
@@ -683,6 +1575,7 @@ function childFrame(
|
|
|
683
1575
|
title: node.label,
|
|
684
1576
|
index: 0,
|
|
685
1577
|
query: "",
|
|
1578
|
+
hover: `menu:${node.id}`,
|
|
686
1579
|
build: (context) => {
|
|
687
1580
|
const fresh = parent
|
|
688
1581
|
.build(context)
|
|
@@ -705,21 +1598,29 @@ export class EditMenu {
|
|
|
705
1598
|
return this.stack.length > 0;
|
|
706
1599
|
}
|
|
707
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
|
+
|
|
708
1606
|
/** Open at the root, or at a section id (`effects`, `automation`). */
|
|
709
1607
|
show(context: MenuContext, section?: string): void {
|
|
710
1608
|
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
711
1609
|
this.filtering = false;
|
|
712
1610
|
this.entry = undefined;
|
|
713
|
-
|
|
714
|
-
|
|
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);
|
|
715
1617
|
const index = nodes.findIndex(
|
|
716
|
-
(node) => node.kind === "menu" && node.id ===
|
|
1618
|
+
(node) => node.kind === "menu" && node.id === id,
|
|
717
1619
|
);
|
|
718
1620
|
const node = nodes[index];
|
|
719
|
-
if (node?.kind
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
1621
|
+
if (node?.kind !== "menu") break;
|
|
1622
|
+
frame.index = index;
|
|
1623
|
+
this.stack.push(childFrame(frame, node));
|
|
723
1624
|
}
|
|
724
1625
|
}
|
|
725
1626
|
|
|
@@ -787,6 +1688,13 @@ export class EditMenu {
|
|
|
787
1688
|
frame.query = "";
|
|
788
1689
|
return { type: "handled" };
|
|
789
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" };
|
|
790
1698
|
this.stack.pop();
|
|
791
1699
|
return this.stack.length ? { type: "handled" } : { type: "close" };
|
|
792
1700
|
}
|
|
@@ -799,21 +1707,43 @@ export class EditMenu {
|
|
|
799
1707
|
const step = KEY_UP.has(value) ? -1 : 1;
|
|
800
1708
|
if (nodes.length)
|
|
801
1709
|
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
802
|
-
return
|
|
1710
|
+
return this.hovered(frame, context);
|
|
803
1711
|
}
|
|
804
1712
|
if (value === "\u001b[5~" || value === "\u001b[H") {
|
|
805
1713
|
frame.index = 0;
|
|
806
|
-
return
|
|
1714
|
+
return this.hovered(frame, context);
|
|
807
1715
|
}
|
|
808
1716
|
if (value === "\u001b[6~" || value === "\u001b[F") {
|
|
809
1717
|
frame.index = Math.max(0, nodes.length - 1);
|
|
810
|
-
return
|
|
1718
|
+
return this.hovered(frame, context);
|
|
811
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 };
|
|
812
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" };
|
|
813
1735
|
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
814
1736
|
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
815
1737
|
return this.nudge(node, direction);
|
|
816
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
|
+
}
|
|
817
1747
|
if (KEY_DELETE.has(value) && node.kind === "point")
|
|
818
1748
|
return {
|
|
819
1749
|
type: "run",
|
|
@@ -844,13 +1774,19 @@ export class EditMenu {
|
|
|
844
1774
|
}
|
|
845
1775
|
if (node.kind === "toggle")
|
|
846
1776
|
return { type: "run", command: node.command(!node.value) };
|
|
847
|
-
if (node.kind === "action")
|
|
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 };
|
|
848
1783
|
if (node.kind === "choice") {
|
|
849
1784
|
const current = node.options.indexOf(node.value);
|
|
850
1785
|
this.stack.push({
|
|
851
1786
|
title: node.label,
|
|
852
1787
|
index: Math.max(0, current),
|
|
853
1788
|
query: "",
|
|
1789
|
+
hover: node.label,
|
|
854
1790
|
build: (fresh) => {
|
|
855
1791
|
const parent = this.stack.at(-2);
|
|
856
1792
|
const live =
|
|
@@ -882,6 +1818,16 @@ export class EditMenu {
|
|
|
882
1818
|
return { type: "handled" };
|
|
883
1819
|
}
|
|
884
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
|
+
|
|
885
1831
|
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
886
1832
|
if (node.kind === "menu") {
|
|
887
1833
|
if (direction > 0) {
|
|
@@ -901,8 +1847,8 @@ export class EditMenu {
|
|
|
901
1847
|
return { type: "run", command: node.command(next) };
|
|
902
1848
|
}
|
|
903
1849
|
if (node.kind === "point") {
|
|
904
|
-
const { min, max } =
|
|
905
|
-
const next = clamp(
|
|
1850
|
+
const { min, max } = automationRange(node.lane);
|
|
1851
|
+
const next = clamp(laneStep(node.lane)(node.value, direction), min, max);
|
|
906
1852
|
if (Math.abs(next - node.value) < 1e-9) return { type: "handled" };
|
|
907
1853
|
return {
|
|
908
1854
|
type: "run",
|
|
@@ -923,7 +1869,11 @@ export class EditMenu {
|
|
|
923
1869
|
? { type: "handled" }
|
|
924
1870
|
: { type: "run", command: node.command(next) };
|
|
925
1871
|
}
|
|
926
|
-
if (direction < 0 && this.stack.length > 1)
|
|
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
|
+
}
|
|
927
1877
|
return { type: "handled" };
|
|
928
1878
|
}
|
|
929
1879
|
|
|
@@ -973,23 +1923,75 @@ export class EditMenu {
|
|
|
973
1923
|
if (frame?.query || this.filtering) title += ` · /${frame?.query ?? ""}`;
|
|
974
1924
|
if (this.entry)
|
|
975
1925
|
title += ` · ${this.entry.label}: ${this.entry.buffer || placeholderFor(selected)}▏`;
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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(" ");
|
|
981
1956
|
const hint = this.entry
|
|
982
|
-
?
|
|
1957
|
+
? HINTS.typing
|
|
983
1958
|
: this.filtering
|
|
984
|
-
?
|
|
985
|
-
: selected?.kind === "
|
|
986
|
-
?
|
|
987
|
-
:
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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 "";
|
|
993
1995
|
}
|
|
994
1996
|
}
|
|
995
1997
|
|
|
@@ -1013,7 +2015,7 @@ function entryCommand(node: MenuNode, text: string): string | undefined {
|
|
|
1013
2015
|
}
|
|
1014
2016
|
if (node.kind === "point") {
|
|
1015
2017
|
// `0.8` sets the value; `6:0.8` moves to beat 6.
|
|
1016
|
-
const { min, max } =
|
|
2018
|
+
const { min, max } = automationRange(node.lane);
|
|
1017
2019
|
const match = trimmed.match(/^(?:(\d+(?:\.\d+)?):)?(-?\d+(?:\.\d+)?)$/);
|
|
1018
2020
|
if (!match) return undefined;
|
|
1019
2021
|
const value = Number(match[2]);
|
|
@@ -1049,6 +2051,40 @@ function valueText(node: MenuNode): string {
|
|
|
1049
2051
|
}
|
|
1050
2052
|
|
|
1051
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
|
+
|
|
1052
2088
|
function commandText(node: MenuNode): string | undefined {
|
|
1053
2089
|
switch (node.kind) {
|
|
1054
2090
|
case "number":
|
|
@@ -1074,10 +2110,16 @@ function nodeText(node: MenuNode): string {
|
|
|
1074
2110
|
|
|
1075
2111
|
/** Root sections, for `/menu <section>`. */
|
|
1076
2112
|
export const MENU_SECTIONS = [
|
|
1077
|
-
"
|
|
1078
|
-
"parameters",
|
|
2113
|
+
"sound",
|
|
1079
2114
|
"effects",
|
|
1080
|
-
"
|
|
2115
|
+
"rhythm",
|
|
2116
|
+
"chords",
|
|
1081
2117
|
"mix",
|
|
2118
|
+
"project",
|
|
2119
|
+
// Older names, still accepted.
|
|
2120
|
+
"parameters",
|
|
2121
|
+
"sounds",
|
|
2122
|
+
"track",
|
|
2123
|
+
"automation",
|
|
1082
2124
|
"transport",
|
|
1083
2125
|
] as const;
|