@hraness/dawg 0.3.0 → 0.4.1
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 +138 -0
- package/DAWG.md +608 -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 +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -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 +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -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 +411 -295
- 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 +1222 -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 +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -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 +286 -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,36 @@ 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;
|
|
122
|
+
/**
|
|
123
|
+
* Which commands stage while auditioning (default `isStageable`); the
|
|
124
|
+
* chord settings screen also stages `/chords …` and `key …`.
|
|
125
|
+
*/
|
|
126
|
+
stageable?: (command: string) => boolean;
|
|
127
|
+
/** The committed chord settings, for `staged ← committed` on Chords rows. */
|
|
128
|
+
committedChords?: ChordSettings;
|
|
30
129
|
}>;
|
|
31
130
|
|
|
32
131
|
type NumberField = Readonly<{
|
|
@@ -44,9 +143,11 @@ type NumberField = Readonly<{
|
|
|
44
143
|
off?: string;
|
|
45
144
|
/** Starting value for a nudge while off. */
|
|
46
145
|
start?: number;
|
|
146
|
+
/** The command `x` runs to put the value back to its default. */
|
|
147
|
+
reset?: string;
|
|
47
148
|
}>;
|
|
48
149
|
|
|
49
|
-
export type MenuNode =
|
|
150
|
+
export type MenuNode = (
|
|
50
151
|
| Readonly<{
|
|
51
152
|
kind: "menu";
|
|
52
153
|
id: string;
|
|
@@ -87,13 +188,33 @@ export type MenuNode =
|
|
|
87
188
|
beat: number;
|
|
88
189
|
value: number;
|
|
89
190
|
}>
|
|
90
|
-
| Readonly<{ kind: "info"; label: string; value: string }
|
|
191
|
+
| Readonly<{ kind: "info"; label: string; value: string }>
|
|
192
|
+
) &
|
|
193
|
+
Readonly<{
|
|
194
|
+
/** One line on what the focused row does, shown under the list. */
|
|
195
|
+
help?: string;
|
|
196
|
+
}>;
|
|
91
197
|
|
|
92
198
|
/** What a key did; `run` carries the command to execute. */
|
|
93
199
|
export type MenuResult =
|
|
94
200
|
| { type: "handled" }
|
|
95
201
|
| { type: "close" }
|
|
96
202
|
| { type: "run"; command: string }
|
|
203
|
+
/** Space (loop), `a` (A/B) or `c` (context) for the audition loop. */
|
|
204
|
+
| { type: "audition"; key: AuditionKey }
|
|
205
|
+
/** Commit the staged edits as one operation. */
|
|
206
|
+
| { type: "keep" }
|
|
207
|
+
/** Drop the staged edits. */
|
|
208
|
+
| { type: "revert" }
|
|
209
|
+
/**
|
|
210
|
+
* The cursor moved in a list while the loop plays: audition `command`
|
|
211
|
+
* in place of the previous hover of `key` (one per list).
|
|
212
|
+
*/
|
|
213
|
+
| { type: "hover"; command: string; key: string }
|
|
214
|
+
/** Left a list without choosing: drop its hover (`key`). */
|
|
215
|
+
| { type: "unhover"; key: string }
|
|
216
|
+
/** Chose a list item while the loop plays: stage it for good. */
|
|
217
|
+
| { type: "choose"; command: string; key: string }
|
|
97
218
|
| { type: "pass" };
|
|
98
219
|
|
|
99
220
|
export type MenuView = Readonly<{
|
|
@@ -101,6 +222,8 @@ export type MenuView = Readonly<{
|
|
|
101
222
|
items: PickerItem[];
|
|
102
223
|
index: number;
|
|
103
224
|
hint: string;
|
|
225
|
+
/** What the focused row does, and the prompt command it stands for. */
|
|
226
|
+
note: string;
|
|
104
227
|
}>;
|
|
105
228
|
|
|
106
229
|
// ── formatting and steps ──────────────────────────────────────────────
|
|
@@ -137,8 +260,8 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
137
260
|
return Math.min(max, Math.max(min, value));
|
|
138
261
|
}
|
|
139
262
|
|
|
140
|
-
const
|
|
141
|
-
Record<
|
|
263
|
+
const TRACK_LANE_STEP: Readonly<
|
|
264
|
+
Record<TrackAutomationParameter, (value: number, direction: 1 | -1) => number>
|
|
142
265
|
> = {
|
|
143
266
|
volume: linear(0.05, 0, SCORE_LIMITS.maxVolume),
|
|
144
267
|
pan: linear(0.1, -1, 1),
|
|
@@ -146,20 +269,65 @@ const LANE_STEP: Readonly<
|
|
|
146
269
|
resonance: linear(0.05, 0, SCORE_LIMITS.maxFilterResonance),
|
|
147
270
|
"delay-feedback": linear(0.05, 0, SCORE_LIMITS.maxDelayFeedback),
|
|
148
271
|
"delay-mix": linear(0.05, 0, SCORE_LIMITS.maxDelayMix),
|
|
272
|
+
wt: linear(0.05, 0, 1),
|
|
149
273
|
};
|
|
150
274
|
|
|
151
|
-
const
|
|
275
|
+
const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
152
276
|
volume: "volume",
|
|
153
277
|
pan: "pan",
|
|
154
278
|
filter: "filter cutoff (Hz)",
|
|
155
279
|
resonance: "filter resonance",
|
|
156
280
|
"delay-feedback": "delay feedback",
|
|
157
281
|
"delay-mix": "delay mix",
|
|
282
|
+
wt: "wavetable position",
|
|
158
283
|
};
|
|
159
284
|
|
|
285
|
+
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
286
|
+
|
|
287
|
+
/** Nudge for a number spec: linear by its step, or a sixth of an octave. */
|
|
288
|
+
function specStep(
|
|
289
|
+
spec: NumberParam,
|
|
290
|
+
): (value: number, direction: 1 | -1) => number {
|
|
291
|
+
if (spec.step === "log")
|
|
292
|
+
return (value, direction) => {
|
|
293
|
+
const next = Math.max(value, 1) * 2 ** (direction / 6);
|
|
294
|
+
return clamp(
|
|
295
|
+
next >= 100 ? Math.round(next) : Math.round(next * 10) / 10,
|
|
296
|
+
spec.min,
|
|
297
|
+
spec.max,
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
const size = spec.step;
|
|
301
|
+
return (value, direction) => {
|
|
302
|
+
const next = Math.round((value + size * direction) / size) * size;
|
|
303
|
+
return clamp(Number(next.toFixed(6)), spec.min, spec.max);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function laneStep(
|
|
308
|
+
lane: AutomationParameter,
|
|
309
|
+
): (value: number, direction: 1 | -1) => number {
|
|
310
|
+
if (isTrackAutomationParameter(lane)) return TRACK_LANE_STEP[lane];
|
|
311
|
+
const info = FX_LANE_INFO.get(lane);
|
|
312
|
+
return info ? specStep(info.spec) : linear(0.05, 0, 1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function laneLabel(lane: AutomationParameter): string {
|
|
316
|
+
if (isTrackAutomationParameter(lane)) return TRACK_LANE_LABEL[lane];
|
|
317
|
+
const info = FX_LANE_INFO.get(lane);
|
|
318
|
+
if (!info) return lane;
|
|
319
|
+
const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
|
|
320
|
+
const owner =
|
|
321
|
+
info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
|
|
322
|
+
return `${owner} ${info.param}${unit}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
160
325
|
/** `volume 0.8`, `pan -0.3`: the command that sets a lane's static value. */
|
|
161
326
|
function laneFormat(lane: AutomationParameter, value: number): string {
|
|
162
|
-
|
|
327
|
+
if (lane === "filter") return `${Math.round(value)}`;
|
|
328
|
+
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
329
|
+
if (info?.spec.step === "log" && value >= 100) return `${Math.round(value)}`;
|
|
330
|
+
return num(value);
|
|
163
331
|
}
|
|
164
332
|
|
|
165
333
|
// ── the tree ──────────────────────────────────────────────────────────
|
|
@@ -171,60 +339,275 @@ function focused(context: MenuContext): Track | undefined {
|
|
|
171
339
|
export function rootNodes(context: MenuContext): MenuNode[] {
|
|
172
340
|
const track = focused(context);
|
|
173
341
|
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(" ");
|
|
342
|
+
const effects = EFFECT_NAMES.filter((effect) => effectValues(track, effect));
|
|
343
|
+
const automated = AUTOMATION_PARAMETERS.filter(
|
|
344
|
+
(lane) => automationPoints(track, lane).length > 0,
|
|
345
|
+
).length;
|
|
346
|
+
const tracks = context.score.tracks.length;
|
|
182
347
|
return [
|
|
183
348
|
{
|
|
184
349
|
kind: "menu",
|
|
185
|
-
id: "
|
|
186
|
-
label: "
|
|
350
|
+
id: "sound",
|
|
351
|
+
label: "Sound",
|
|
187
352
|
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
{
|
|
191
|
-
kind: "menu",
|
|
192
|
-
id: "parameters",
|
|
193
|
-
label: "Parameters",
|
|
194
|
-
detail: `${track?.instrument ?? "?"}`,
|
|
195
|
-
build: parameterNodes,
|
|
353
|
+
help: "the focused track's instrument and its voice",
|
|
354
|
+
build: soundSectionNodes,
|
|
196
355
|
},
|
|
197
356
|
{
|
|
198
357
|
kind: "menu",
|
|
199
358
|
id: "effects",
|
|
200
359
|
label: "Effects",
|
|
201
|
-
detail: effects.length ? effects.join(" · ") : "none",
|
|
360
|
+
detail: effects.length ? effects.join(" · ") : "none on",
|
|
361
|
+
help: "filter, delay, reverb and more on the focused track",
|
|
202
362
|
build: effectNodes,
|
|
203
363
|
},
|
|
204
364
|
{
|
|
205
365
|
kind: "menu",
|
|
206
|
-
id: "
|
|
207
|
-
label: "
|
|
208
|
-
detail:
|
|
209
|
-
|
|
366
|
+
id: "rhythm",
|
|
367
|
+
label: "Rhythm",
|
|
368
|
+
detail: "euclid editor · patterns · kits",
|
|
369
|
+
help: "drum hits per voice, ready-made grooves and drum kits",
|
|
370
|
+
build: rhythmNodes,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
kind: "menu",
|
|
374
|
+
id: "chords",
|
|
375
|
+
label: "Chords",
|
|
376
|
+
detail: chordsDetail(context),
|
|
377
|
+
help: "song key and how chords play in play mode (Ctrl-P)",
|
|
378
|
+
build: chordNodes,
|
|
210
379
|
},
|
|
211
380
|
{
|
|
212
381
|
kind: "menu",
|
|
213
382
|
id: "mix",
|
|
214
|
-
label: "Mix",
|
|
215
|
-
detail: `${
|
|
216
|
-
|
|
383
|
+
label: "Mix & automation",
|
|
384
|
+
detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
|
|
385
|
+
help: "volume, pan, mute and solo per track; values that change over time",
|
|
386
|
+
build: mixSectionNodes,
|
|
217
387
|
},
|
|
218
388
|
{
|
|
219
389
|
kind: "menu",
|
|
220
|
-
id: "
|
|
221
|
-
label: "
|
|
222
|
-
detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars
|
|
390
|
+
id: "project",
|
|
391
|
+
label: "Project",
|
|
392
|
+
detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
|
|
393
|
+
help: "tempo, meter, loop length, play, click and count-in",
|
|
223
394
|
build: transportNodes,
|
|
224
395
|
},
|
|
225
396
|
];
|
|
226
397
|
}
|
|
227
398
|
|
|
399
|
+
/** Old section names still open the menu where that content now lives. */
|
|
400
|
+
const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
401
|
+
sound: ["sound"],
|
|
402
|
+
parameters: ["sound"],
|
|
403
|
+
sounds: ["sound", "browse"],
|
|
404
|
+
effects: ["effects"],
|
|
405
|
+
rhythm: ["rhythm"],
|
|
406
|
+
chords: ["chords"],
|
|
407
|
+
mix: ["mix"],
|
|
408
|
+
track: ["mix"],
|
|
409
|
+
automation: ["mix", "automation"],
|
|
410
|
+
project: ["project"],
|
|
411
|
+
transport: ["project"],
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/** Sound: instrument and voice first, then the sound browser. */
|
|
415
|
+
function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
416
|
+
return [
|
|
417
|
+
...parameterNodes(context),
|
|
418
|
+
{
|
|
419
|
+
kind: "menu",
|
|
420
|
+
id: "browse",
|
|
421
|
+
label: "browse sounds",
|
|
422
|
+
detail: soundsDetail(focused(context)),
|
|
423
|
+
help: "instruments, wavetables, sample packs",
|
|
424
|
+
build: soundNodes,
|
|
425
|
+
},
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Rhythm: the euclid editor, then grooves and kits. */
|
|
430
|
+
function rhythmNodes(): MenuNode[] {
|
|
431
|
+
return [
|
|
432
|
+
{
|
|
433
|
+
kind: "action",
|
|
434
|
+
label: "euclid editor",
|
|
435
|
+
command: "/euclid",
|
|
436
|
+
help: "hits, steps and rotation per drum voice",
|
|
437
|
+
},
|
|
438
|
+
patternsMenu(),
|
|
439
|
+
kitsMenu(),
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Mix & automation: this track, every track, then automation lanes. */
|
|
444
|
+
function mixSectionNodes(context: MenuContext): MenuNode[] {
|
|
445
|
+
const track = focused(context);
|
|
446
|
+
const automated = AUTOMATION_PARAMETERS.filter(
|
|
447
|
+
(lane) => automationPoints(track, lane).length > 0,
|
|
448
|
+
).join(" ");
|
|
449
|
+
return [
|
|
450
|
+
...trackNodes(context),
|
|
451
|
+
{
|
|
452
|
+
kind: "menu",
|
|
453
|
+
id: "tracks",
|
|
454
|
+
label: "all tracks",
|
|
455
|
+
detail: `${context.score.tracks.length} · focus another track`,
|
|
456
|
+
help: "pick a track to edit; ● is the focused one",
|
|
457
|
+
build: mixNodes,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
kind: "menu",
|
|
461
|
+
id: "automation",
|
|
462
|
+
label: "automation",
|
|
463
|
+
detail: automated || "no lanes yet",
|
|
464
|
+
help: "points that move a value over the loop",
|
|
465
|
+
build: automationNodes,
|
|
466
|
+
},
|
|
467
|
+
];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function chordsDetail(context: MenuContext): string {
|
|
471
|
+
const chords = context.chords ?? defaultChordSettings();
|
|
472
|
+
const key = parseKey(context.score.key ?? undefined);
|
|
473
|
+
return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Play mode's chord settings and the song key (`/chords …`, `key …`). */
|
|
477
|
+
function chordNodes(context: MenuContext): MenuNode[] {
|
|
478
|
+
const chords = context.chords ?? defaultChordSettings();
|
|
479
|
+
const key = parseKey(context.score.key ?? undefined);
|
|
480
|
+
const tonic = key ? keyName(key).split(" ")[0]! : "C";
|
|
481
|
+
const mode = key?.mode ?? "major";
|
|
482
|
+
const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
|
|
483
|
+
return [
|
|
484
|
+
{
|
|
485
|
+
kind: "choice",
|
|
486
|
+
label: "mode",
|
|
487
|
+
help: "auto: keys play chords in the song key · manual: latch 1–8 · off",
|
|
488
|
+
value: chords.mode,
|
|
489
|
+
options: CHORD_MODES,
|
|
490
|
+
command: (option) => `/chords ${option}`,
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
kind: "choice",
|
|
494
|
+
label: "key tonic",
|
|
495
|
+
help: "the song key; auto chord mode follows it",
|
|
496
|
+
value: tonic,
|
|
497
|
+
options: TONICS,
|
|
498
|
+
command: (option) => `key ${option} ${mode}`,
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
kind: "choice",
|
|
502
|
+
label: "key mode",
|
|
503
|
+
help: "major, minor or a church mode",
|
|
504
|
+
value: mode,
|
|
505
|
+
options: MODE_NAMES,
|
|
506
|
+
command: (option) => `key ${tonic} ${option}`,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
kind: "number",
|
|
510
|
+
label: "voicing",
|
|
511
|
+
help: "inversion steps up or down (play mode: - =)",
|
|
512
|
+
value: chords.inversion,
|
|
513
|
+
min: -MAX_VOICING_STEP,
|
|
514
|
+
max: MAX_VOICING_STEP,
|
|
515
|
+
step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
|
|
516
|
+
format: (value) => (value > 0 ? `+${value}` : String(value)),
|
|
517
|
+
command: (value) => `/chords voicing ${Math.round(value)}`,
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
kind: "choice",
|
|
521
|
+
label: "spread",
|
|
522
|
+
help: "close, open or wide chord voicing",
|
|
523
|
+
value: chords.spread,
|
|
524
|
+
options: SPREADS,
|
|
525
|
+
command: (option) => `/chords spread ${option}`,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
kind: "choice",
|
|
529
|
+
label: "bass",
|
|
530
|
+
help: "what the left hand plays under the chord (play mode: b)",
|
|
531
|
+
value: chords.bass,
|
|
532
|
+
options: BASS_MODES,
|
|
533
|
+
command: (option) => `/chords bass ${option}`,
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
kind: "toggle",
|
|
537
|
+
label: "sevenths",
|
|
538
|
+
help: "auto mode plays sevenths instead of triads",
|
|
539
|
+
value: chords.sevenths,
|
|
540
|
+
command: (on) => `/chords sevenths ${on ? "on" : "off"}`,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
kind: "choice",
|
|
544
|
+
label: "perform",
|
|
545
|
+
help: "block, strum, arpeggio or a rhythm pattern (play mode: 9)",
|
|
546
|
+
value: chords.perform,
|
|
547
|
+
options: PERFORM_MODES,
|
|
548
|
+
command: (option) => `/chords perform ${option}`,
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
kind: "choice",
|
|
552
|
+
label: "pattern",
|
|
553
|
+
help: "the rhythm used when perform is pattern",
|
|
554
|
+
value: chords.pattern,
|
|
555
|
+
options: CHORD_PATTERNS.map((pattern) => pattern.name),
|
|
556
|
+
command: (option) => `/chords pattern ${option}`,
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
kind: "choice",
|
|
560
|
+
label: "arp rate",
|
|
561
|
+
help: "arpeggio step length",
|
|
562
|
+
value: chords.rate,
|
|
563
|
+
options: ARP_RATES,
|
|
564
|
+
command: (option) => `/chords rate ${option}`,
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
kind: "number",
|
|
568
|
+
label: "arp octaves",
|
|
569
|
+
help: "how many octaves an arpeggio climbs",
|
|
570
|
+
value: chords.octaves,
|
|
571
|
+
min: 1,
|
|
572
|
+
max: 4,
|
|
573
|
+
step: linear(1, 1, 4),
|
|
574
|
+
format: (value) => `${num(value)} oct`,
|
|
575
|
+
command: (value) => `/chords octaves ${Math.round(value)}`,
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
kind: "choice",
|
|
579
|
+
label: "progression",
|
|
580
|
+
help: "a preset the n key walks through",
|
|
581
|
+
value: chords.preset,
|
|
582
|
+
options: ["none", ...presets],
|
|
583
|
+
command: (option) => `/chords preset ${option}`,
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
kind: "choice",
|
|
587
|
+
label: "style",
|
|
588
|
+
help: "the style n suggests next chords in",
|
|
589
|
+
value: chords.style,
|
|
590
|
+
options: PROGRESSION_STYLES,
|
|
591
|
+
command: (option) => `/chords style ${option}`,
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const TONICS = [
|
|
597
|
+
"C",
|
|
598
|
+
"Db",
|
|
599
|
+
"D",
|
|
600
|
+
"Eb",
|
|
601
|
+
"E",
|
|
602
|
+
"F",
|
|
603
|
+
"F#",
|
|
604
|
+
"G",
|
|
605
|
+
"Ab",
|
|
606
|
+
"A",
|
|
607
|
+
"Bb",
|
|
608
|
+
"B",
|
|
609
|
+
] as const;
|
|
610
|
+
|
|
228
611
|
function trackNodes(context: MenuContext): MenuNode[] {
|
|
229
612
|
const track = focused(context);
|
|
230
613
|
if (!track)
|
|
@@ -238,19 +621,21 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
238
621
|
command: (text) =>
|
|
239
622
|
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
240
623
|
example: `track name ${track.name}`,
|
|
624
|
+
help: "rename the focused track",
|
|
241
625
|
},
|
|
242
|
-
instrumentNode(track),
|
|
243
626
|
{
|
|
244
627
|
kind: "toggle",
|
|
245
628
|
label: "mute",
|
|
246
629
|
value: track.muted,
|
|
247
630
|
command: (on) => (on ? "mute" : "unmute"),
|
|
631
|
+
help: "silence this track",
|
|
248
632
|
},
|
|
249
633
|
{
|
|
250
634
|
kind: "toggle",
|
|
251
635
|
label: "solo",
|
|
252
636
|
value: track.solo === true,
|
|
253
637
|
command: (on) => (on ? "solo" : "unsolo"),
|
|
638
|
+
help: "hear only soloed tracks",
|
|
254
639
|
},
|
|
255
640
|
volumeNode(track),
|
|
256
641
|
panNode(track),
|
|
@@ -266,6 +651,7 @@ function instrumentNode(track: Track): MenuNode {
|
|
|
266
651
|
value: track.instrument,
|
|
267
652
|
options,
|
|
268
653
|
command: (option) => `instrument ${option}`,
|
|
654
|
+
help: "the sound source; drums use kit",
|
|
269
655
|
};
|
|
270
656
|
}
|
|
271
657
|
|
|
@@ -276,9 +662,12 @@ function volumeNode(track: Track): MenuNode {
|
|
|
276
662
|
value: track.volume,
|
|
277
663
|
min: 0,
|
|
278
664
|
max: SCORE_LIMITS.maxVolume,
|
|
279
|
-
step:
|
|
280
|
-
format:
|
|
665
|
+
step: TRACK_LANE_STEP.volume,
|
|
666
|
+
format: (value) =>
|
|
667
|
+
`${num(value)} · ${value > 0 ? `${(20 * Math.log10(value)).toFixed(1)} dB` : "silent"}`,
|
|
281
668
|
command: (value) => `volume ${num(value)}`,
|
|
669
|
+
reset: "volume 1",
|
|
670
|
+
help: "track gain; 1 is unity (0 dB) · x resets",
|
|
282
671
|
};
|
|
283
672
|
}
|
|
284
673
|
|
|
@@ -289,9 +678,14 @@ function panNode(track: Track): MenuNode {
|
|
|
289
678
|
value: track.pan,
|
|
290
679
|
min: -1,
|
|
291
680
|
max: 1,
|
|
292
|
-
step:
|
|
293
|
-
format:
|
|
681
|
+
step: TRACK_LANE_STEP.pan,
|
|
682
|
+
format: (value) =>
|
|
683
|
+
value === 0
|
|
684
|
+
? "center"
|
|
685
|
+
: `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
|
|
294
686
|
command: (value) => `pan ${num(value)}`,
|
|
687
|
+
reset: "pan 0",
|
|
688
|
+
help: "-1 left … 1 right · x centres",
|
|
295
689
|
};
|
|
296
690
|
}
|
|
297
691
|
|
|
@@ -299,6 +693,9 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
299
693
|
const track = focused(context);
|
|
300
694
|
if (!track) return [];
|
|
301
695
|
const nodes: MenuNode[] = [instrumentNode(track)];
|
|
696
|
+
// A wavetable track also has the synth voice's envelope, filters and FM.
|
|
697
|
+
if (isWavetableInstrument(track.instrument))
|
|
698
|
+
nodes.push(...wavetableNodes(track, context.projectRoot));
|
|
302
699
|
if (track.sampler) {
|
|
303
700
|
nodes.push({
|
|
304
701
|
kind: "info",
|
|
@@ -312,9 +709,12 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
312
709
|
ref.speed !== undefined ? `speed ${num(ref.speed)}` : "",
|
|
313
710
|
].filter(Boolean);
|
|
314
711
|
nodes.push({
|
|
315
|
-
kind: "
|
|
712
|
+
kind: "menu",
|
|
713
|
+
id: `sample:${voice}`,
|
|
316
714
|
label: voice,
|
|
317
|
-
|
|
715
|
+
detail: [ref.src.split("/").at(-1), ...extras].join(" · "),
|
|
716
|
+
help: "this voice's sample controls (Strudel names)",
|
|
717
|
+
build: (context) => sampleVoiceNodes(context, voice),
|
|
318
718
|
});
|
|
319
719
|
}
|
|
320
720
|
nodes.push({
|
|
@@ -322,188 +722,546 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
322
722
|
label: "add a voice",
|
|
323
723
|
value: "/sample <path> [as <voice>]",
|
|
324
724
|
});
|
|
325
|
-
} else {
|
|
725
|
+
} else if (!isDrumInstrument(track.instrument)) {
|
|
726
|
+
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
326
727
|
nodes.push({
|
|
327
|
-
kind: "
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
728
|
+
kind: "menu",
|
|
729
|
+
id: "synth:advanced",
|
|
730
|
+
label: "advanced",
|
|
731
|
+
detail: `all ${Object.keys(SYNTH_PARAMS).length} params`,
|
|
732
|
+
help: "every voice parameter, grouped, under its Strudel name",
|
|
733
|
+
build: synthAdvancedNodes,
|
|
331
734
|
});
|
|
735
|
+
if (track.synth)
|
|
736
|
+
nodes.push({
|
|
737
|
+
kind: "action",
|
|
738
|
+
label: "reset to defaults",
|
|
739
|
+
command: "synth reset",
|
|
740
|
+
help: "clear every voice setting on this track",
|
|
741
|
+
});
|
|
332
742
|
}
|
|
333
743
|
return nodes;
|
|
334
744
|
}
|
|
335
745
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
746
|
+
/** Number rows for `/sample set`: [control, min, max, step, default]. */
|
|
747
|
+
const SAMPLE_NUMBER_ROWS: readonly (readonly [
|
|
748
|
+
SampleControl,
|
|
749
|
+
number,
|
|
750
|
+
number,
|
|
751
|
+
number,
|
|
752
|
+
number,
|
|
753
|
+
])[] = [
|
|
754
|
+
["begin", 0, 0.99, 0.01, 0],
|
|
755
|
+
["end", 0.01, 1, 0.01, 1],
|
|
756
|
+
["gain", 0, SCORE_LIMITS.maxSampleGain, 0.05, 1],
|
|
757
|
+
["speed", -SCORE_LIMITS.maxSampleSpeed, SCORE_LIMITS.maxSampleSpeed, 0.05, 1],
|
|
758
|
+
["loopBegin", 0, 0.99, 0.01, 0],
|
|
759
|
+
["loopEnd", 0.01, 1, 0.01, 1],
|
|
760
|
+
["clip", 0.05, SCORE_LIMITS.maxSampleClip, 0.05, 1],
|
|
761
|
+
[
|
|
762
|
+
"accelerate",
|
|
763
|
+
-SCORE_LIMITS.maxSampleAccelerate,
|
|
764
|
+
SCORE_LIMITS.maxSampleAccelerate,
|
|
765
|
+
0.1,
|
|
766
|
+
0,
|
|
767
|
+
],
|
|
768
|
+
["squiz", 1, SCORE_LIMITS.maxSampleSquiz, 0.5, 1],
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
/** One sampler voice: every sample control, run through `/sample set`. */
|
|
772
|
+
function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
773
|
+
const track = context.score.tracks.find(
|
|
774
|
+
(item) => item.id === context.trackId,
|
|
775
|
+
);
|
|
776
|
+
const ref = track?.sampler?.voices[voice];
|
|
777
|
+
if (!ref) return [];
|
|
778
|
+
const set = (rest: string) => `/sample set ${voice} ${rest}`;
|
|
779
|
+
const nodes: MenuNode[] = [{ kind: "info", label: "file", value: ref.src }];
|
|
780
|
+
for (const [control, min, max, step, start] of SAMPLE_NUMBER_ROWS) {
|
|
781
|
+
const current = (ref as Record<string, unknown>)[control];
|
|
782
|
+
nodes.push({
|
|
783
|
+
kind: "number",
|
|
784
|
+
label: control,
|
|
785
|
+
value: typeof current === "number" ? current : undefined,
|
|
786
|
+
start,
|
|
787
|
+
off: num(start),
|
|
788
|
+
min,
|
|
789
|
+
max,
|
|
790
|
+
step:
|
|
791
|
+
control === "speed"
|
|
792
|
+
? (value, direction) => {
|
|
793
|
+
// Speed 0 is invalid: step over it.
|
|
794
|
+
const next = linear(step, min, max)(value, direction);
|
|
795
|
+
return next === 0 ? step * direction : next;
|
|
796
|
+
}
|
|
797
|
+
: linear(step, min, max),
|
|
798
|
+
format: num,
|
|
799
|
+
command: (value) => set(`${control} ${num(value)}`),
|
|
800
|
+
reset: set(`${control} off`),
|
|
801
|
+
help: SAMPLE_CONTROLS[control],
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
nodes.push(
|
|
343
805
|
{
|
|
344
|
-
kind: "
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
build: () => [
|
|
351
|
-
{
|
|
352
|
-
kind: "toggle",
|
|
353
|
-
label: "on",
|
|
354
|
-
value: filter !== undefined,
|
|
355
|
-
command: (on) => (on ? "filter 2000 0.2" : "filter off"),
|
|
356
|
-
},
|
|
357
|
-
{
|
|
358
|
-
kind: "number",
|
|
359
|
-
label: "cutoff Hz",
|
|
360
|
-
value: filter?.cutoff,
|
|
361
|
-
start: 2000,
|
|
362
|
-
off: "off",
|
|
363
|
-
min: SCORE_LIMITS.minFilterCutoff,
|
|
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
|
-
],
|
|
806
|
+
kind: "choice",
|
|
807
|
+
label: "unit",
|
|
808
|
+
value: ref.unit ?? "r",
|
|
809
|
+
options: SAMPLE_UNITS,
|
|
810
|
+
command: (option) => set(`unit ${option}`),
|
|
811
|
+
help: SAMPLE_CONTROLS.unit,
|
|
384
812
|
},
|
|
385
813
|
{
|
|
814
|
+
kind: "toggle",
|
|
815
|
+
label: "loop",
|
|
816
|
+
value: ref.loop === true,
|
|
817
|
+
command: (on) => set(`loop ${on ? "on" : "off"}`),
|
|
818
|
+
help: SAMPLE_CONTROLS.loop,
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
kind: "toggle",
|
|
822
|
+
label: "fit",
|
|
823
|
+
value: ref.fit === true,
|
|
824
|
+
command: (on) => set(`fit ${on ? "on" : "off"}`),
|
|
825
|
+
help: SAMPLE_CONTROLS.fit,
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
kind: "info",
|
|
829
|
+
label: "cut / loopAt",
|
|
830
|
+
value: `/sample set ${voice} cut hats · loopAt 2`,
|
|
831
|
+
},
|
|
832
|
+
);
|
|
833
|
+
return nodes;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Synth preset first, then one row per parameter. */
|
|
837
|
+
/** The preset this track's voice still matches exactly, if any. */
|
|
838
|
+
function matchingSynthPreset(track: Track): string | undefined {
|
|
839
|
+
if (!track.synth) return undefined;
|
|
840
|
+
const current = JSON.stringify(track.synth);
|
|
841
|
+
return Object.entries(SYNTH_PRESETS).find(
|
|
842
|
+
([, preset]) =>
|
|
843
|
+
preset.instrument === track.instrument &&
|
|
844
|
+
JSON.stringify(normalizeSynth(preset.synth)) === current,
|
|
845
|
+
)?.[0];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
|
|
849
|
+
const nodes: MenuNode[] = [];
|
|
850
|
+
if (keys === SYNTH_SIMPLE)
|
|
851
|
+
nodes.push({
|
|
852
|
+
kind: "choice",
|
|
853
|
+
label: "preset",
|
|
854
|
+
value: matchingSynthPreset(track) ?? "—",
|
|
855
|
+
options: Object.keys(SYNTH_PRESETS),
|
|
856
|
+
command: (preset) => `synth preset ${preset}`,
|
|
857
|
+
help: "a starting voice; every value stays editable",
|
|
858
|
+
});
|
|
859
|
+
for (const key of keys)
|
|
860
|
+
nodes.push(synthParamNode(track, key, keys !== SYNTH_SIMPLE));
|
|
861
|
+
return nodes;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
|
|
865
|
+
const param = SYNTH_PARAMS[key]!;
|
|
866
|
+
const current = track.synth?.[key];
|
|
867
|
+
const aliases = (param.strudel ?? []).filter((name) => name !== key);
|
|
868
|
+
const label = named
|
|
869
|
+
? aliases.length
|
|
870
|
+
? `${key} (${aliases.join("/")})`
|
|
871
|
+
: key
|
|
872
|
+
: (SYNTH_LABEL[key] ?? key);
|
|
873
|
+
if (param.kind === "number")
|
|
874
|
+
return {
|
|
875
|
+
kind: "number",
|
|
876
|
+
label,
|
|
877
|
+
value: typeof current === "number" ? current : undefined,
|
|
878
|
+
start: param.default,
|
|
879
|
+
// An unset filter cutoff means no filter, not the default cutoff.
|
|
880
|
+
off: /^(lpf|hpf|bpf)$/.test(key)
|
|
881
|
+
? "off"
|
|
882
|
+
: withUnit(formatParam(param, param.default), param.unit),
|
|
883
|
+
min: param.min,
|
|
884
|
+
max: param.max,
|
|
885
|
+
step: specStep(param),
|
|
886
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
887
|
+
command: (value) => `synth ${key} ${formatParam(param, value)}`,
|
|
888
|
+
reset: `synth ${key} off`,
|
|
889
|
+
help: param.doc,
|
|
890
|
+
};
|
|
891
|
+
if (param.kind === "enum")
|
|
892
|
+
return {
|
|
893
|
+
kind: "choice",
|
|
894
|
+
label,
|
|
895
|
+
value: typeof current === "string" ? current : param.default,
|
|
896
|
+
options: param.values,
|
|
897
|
+
command: (option) => `synth ${key} ${option}`,
|
|
898
|
+
help: param.doc,
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
kind: "toggle",
|
|
902
|
+
label,
|
|
903
|
+
value: typeof current === "boolean" ? current : param.default,
|
|
904
|
+
command: (on) => `synth ${key} ${on ? "on" : "off"}`,
|
|
905
|
+
help: param.doc,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Plain names for the simple synth rows; the note shows the command name. */
|
|
910
|
+
const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
911
|
+
lpf: "filter cutoff",
|
|
912
|
+
lpq: "filter res",
|
|
913
|
+
lpenv: "filter env",
|
|
914
|
+
vib: "vibrato",
|
|
915
|
+
fm: "FM amount",
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
/** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
|
|
919
|
+
function synthAdvancedNodes(context: MenuContext): MenuNode[] {
|
|
920
|
+
const track = focused(context);
|
|
921
|
+
if (!track) return [];
|
|
922
|
+
const nodes: MenuNode[] = SYNTH_GROUPS.map((group) => {
|
|
923
|
+
const set = group.params.filter((key) => track.synth?.[key] !== undefined);
|
|
924
|
+
return {
|
|
386
925
|
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
|
-
];
|
|
926
|
+
id: `synth:${group.id}`,
|
|
927
|
+
label: group.label,
|
|
928
|
+
detail: set.length
|
|
929
|
+
? set.map((key) => `${key} ${String(track.synth![key])}`).join(" · ")
|
|
930
|
+
: "defaults",
|
|
931
|
+
build: (inner) => {
|
|
932
|
+
const current = focused(inner);
|
|
933
|
+
return current ? synthNodes(current, group.params) : [];
|
|
442
934
|
},
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
const partials = track.synth?.partials;
|
|
938
|
+
nodes.push({
|
|
939
|
+
kind: "entry",
|
|
940
|
+
label: "partials",
|
|
941
|
+
value: Array.isArray(partials) ? partials.join(" ") : "—",
|
|
942
|
+
placeholder: "harmonic amplitudes, e.g. 1 0.5 0.33",
|
|
943
|
+
command: (text) =>
|
|
944
|
+
text.trim() ? `synth partials ${text.trim()}` : "synth partials off",
|
|
945
|
+
example: "synth partials 1 0.5 0.33 0.25",
|
|
946
|
+
});
|
|
947
|
+
nodes.push({
|
|
948
|
+
kind: "entry",
|
|
949
|
+
label: "zzfx array",
|
|
950
|
+
value: "—",
|
|
951
|
+
placeholder: "a raw ZzFX array, e.g. ,,129,.01,,.15,2",
|
|
952
|
+
command: (text) => `synth zzfx ${text.trim()}`,
|
|
953
|
+
example: "synth zzfx ,,129,.01,,.15,2",
|
|
954
|
+
});
|
|
955
|
+
return nodes;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Short help for each wavetable parameter row. */
|
|
959
|
+
const WAVETABLE_LABELS: Readonly<Record<WavetableParam, string>> = {
|
|
960
|
+
wt: "position (wt)",
|
|
961
|
+
wtenv: "position env amount",
|
|
962
|
+
wtattack: "position env attack s",
|
|
963
|
+
wtdecay: "position env decay s",
|
|
964
|
+
wtsustain: "position env sustain",
|
|
965
|
+
wtrelease: "position env release s",
|
|
966
|
+
wtrate: "position LFO Hz",
|
|
967
|
+
wtdepth: "position LFO depth",
|
|
968
|
+
warp: "warp",
|
|
969
|
+
wtphaserand: "phase randomness",
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const WAVETABLE_STEP: Readonly<Record<WavetableParam, number>> = {
|
|
973
|
+
wt: 0.05,
|
|
974
|
+
wtenv: 0.1,
|
|
975
|
+
wtattack: 0.01,
|
|
976
|
+
wtdecay: 0.05,
|
|
977
|
+
wtsustain: 0.05,
|
|
978
|
+
wtrelease: 0.05,
|
|
979
|
+
wtrate: 0.25,
|
|
980
|
+
wtdepth: 0.05,
|
|
981
|
+
warp: 0.05,
|
|
982
|
+
wtphaserand: 0.1,
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
|
|
986
|
+
const settings = wavetableOf(track);
|
|
987
|
+
const local = listLocalWavetables(projectRoot);
|
|
988
|
+
const tables: MenuNode[] = [
|
|
989
|
+
...local.map((path): MenuNode => ({
|
|
990
|
+
kind: "action",
|
|
991
|
+
label: `${path.split("/").pop()} project · ${path.split("/")[1]}`,
|
|
992
|
+
command: `wt ${path}`,
|
|
993
|
+
})),
|
|
994
|
+
...BUILTIN_TABLE_NAMES.map((name): MenuNode => ({
|
|
995
|
+
kind: "action",
|
|
996
|
+
label: `${name} ${BUILTIN_TABLES[name]!.title} · built-in`,
|
|
997
|
+
command: `wt ${name}`,
|
|
998
|
+
})),
|
|
999
|
+
...Object.entries(UZU_WAVETABLES).map(([set, names]): MenuNode => ({
|
|
1000
|
+
kind: "menu",
|
|
1001
|
+
id: `wt-${set}`,
|
|
1002
|
+
label: set,
|
|
1003
|
+
detail: `${names.length} tables · ${WAVETABLE_PACK}`,
|
|
1004
|
+
build: () =>
|
|
1005
|
+
names.map((name, index): MenuNode => ({
|
|
1006
|
+
kind: "action",
|
|
1007
|
+
label: `${set}:${index} ${name}`,
|
|
1008
|
+
command: `wt ${set}:${index}`,
|
|
1009
|
+
})),
|
|
1010
|
+
})),
|
|
1011
|
+
];
|
|
1012
|
+
const nodes: MenuNode[] = [
|
|
1013
|
+
{
|
|
1014
|
+
kind: "menu",
|
|
1015
|
+
id: "wavetables",
|
|
1016
|
+
label: "table",
|
|
1017
|
+
detail: describeTable(settings.table.src),
|
|
1018
|
+
build: () => tables,
|
|
443
1019
|
},
|
|
1020
|
+
];
|
|
1021
|
+
for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
|
|
1022
|
+
const [min, max, fallback] = WAVETABLE_PARAMS[name];
|
|
1023
|
+
nodes.push({
|
|
1024
|
+
kind: "number",
|
|
1025
|
+
label: WAVETABLE_LABELS[name],
|
|
1026
|
+
value: settings[name] ?? fallback,
|
|
1027
|
+
min,
|
|
1028
|
+
max,
|
|
1029
|
+
step: linear(WAVETABLE_STEP[name], min, max),
|
|
1030
|
+
format: num,
|
|
1031
|
+
command: (value) => `${name} ${num(value)}`,
|
|
1032
|
+
reset: `${name} ${num(fallback)}`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
nodes.push({
|
|
1036
|
+
kind: "choice",
|
|
1037
|
+
label: "warp mode",
|
|
1038
|
+
value: settings.warpmode ?? "none",
|
|
1039
|
+
options: WARP_MODES,
|
|
1040
|
+
command: (option) => `warpmode ${option}`,
|
|
1041
|
+
});
|
|
1042
|
+
if ((track.wtAutomation?.length ?? 0) > 0)
|
|
1043
|
+
nodes.push({
|
|
1044
|
+
kind: "info",
|
|
1045
|
+
label: "position automation",
|
|
1046
|
+
value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
|
|
1047
|
+
});
|
|
1048
|
+
return nodes;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Effects in chain order; each opens on its presets and simple params. */
|
|
1052
|
+
function effectNodes(context: MenuContext): MenuNode[] {
|
|
1053
|
+
const track = focused(context);
|
|
1054
|
+
if (!track) return [];
|
|
1055
|
+
const node = (effect: EffectName): MenuNode => {
|
|
1056
|
+
const spec = effectSpec(effect);
|
|
1057
|
+
const values = effectValues(track, effect);
|
|
1058
|
+
return {
|
|
1059
|
+
kind: "menu",
|
|
1060
|
+
id: effect,
|
|
1061
|
+
label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
|
|
1062
|
+
detail: values ? effectSummary(effect, values) : "off",
|
|
1063
|
+
help: spec.doc,
|
|
1064
|
+
build: (inner) => effectParamNodes(inner, effect, false),
|
|
1065
|
+
};
|
|
1066
|
+
};
|
|
1067
|
+
const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
|
|
1068
|
+
const moreOn = more.filter((effect) => effectValues(track, effect));
|
|
1069
|
+
return [
|
|
1070
|
+
...CORE_EFFECTS.map(node),
|
|
444
1071
|
{
|
|
445
1072
|
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
|
-
],
|
|
1073
|
+
id: "more effects",
|
|
1074
|
+
label: "more effects",
|
|
1075
|
+
help: "less common effects, each with the same controls",
|
|
1076
|
+
detail:
|
|
1077
|
+
moreOn.length > 0
|
|
1078
|
+
? `on: ${moreOn.map((effect) => effectSpec(effect).label).join(", ")}`
|
|
1079
|
+
: more.map((effect) => effectSpec(effect).label).join(", "),
|
|
1080
|
+
build: () => more.map(node),
|
|
488
1081
|
},
|
|
489
1082
|
];
|
|
490
1083
|
}
|
|
491
1084
|
|
|
1085
|
+
function effectSummary(
|
|
1086
|
+
effect: EffectName,
|
|
1087
|
+
values: Readonly<Record<string, number | string | boolean>>,
|
|
1088
|
+
): string {
|
|
1089
|
+
const spec = effectSpec(effect);
|
|
1090
|
+
return spec.simple
|
|
1091
|
+
.filter((key) => values[key] !== undefined)
|
|
1092
|
+
.map((key) => {
|
|
1093
|
+
const value = values[key]!;
|
|
1094
|
+
const param = spec.params[key]!;
|
|
1095
|
+
return `${key} ${typeof value === "number" ? formatParam(param, value) : String(value)}`;
|
|
1096
|
+
})
|
|
1097
|
+
.join(" · ");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** `800 Hz`, `0.2 s`: a value with its unit, when it has one. */
|
|
1101
|
+
function withUnit(text: string, unit: string | undefined): string {
|
|
1102
|
+
return unit ? `${text} ${unit}` : text;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function formatParam(spec: ParamSpec, value: number): string {
|
|
1106
|
+
if (spec.kind !== "number") return num(value);
|
|
1107
|
+
return spec.step === "log" && value >= 100
|
|
1108
|
+
? `${Math.round(value)}`
|
|
1109
|
+
: num(value);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* One effect's rows: on/off, presets, then its simple parameters; the
|
|
1114
|
+
* `advanced` submenu holds every parameter with its Strudel names.
|
|
1115
|
+
*/
|
|
1116
|
+
function effectParamNodes(
|
|
1117
|
+
context: MenuContext,
|
|
1118
|
+
effect: EffectName,
|
|
1119
|
+
advanced: boolean,
|
|
1120
|
+
): MenuNode[] {
|
|
1121
|
+
const track = focused(context);
|
|
1122
|
+
if (!track) return [];
|
|
1123
|
+
const spec = effectSpec(effect);
|
|
1124
|
+
const values = effectValues(track, effect);
|
|
1125
|
+
const nodes: MenuNode[] = [];
|
|
1126
|
+
if (!advanced) {
|
|
1127
|
+
nodes.push({
|
|
1128
|
+
kind: "toggle",
|
|
1129
|
+
label: "on",
|
|
1130
|
+
value: values !== undefined,
|
|
1131
|
+
command: (on) => `fx ${effect} ${on ? "on" : "off"}`,
|
|
1132
|
+
help: `switch the ${spec.label} on or off (settings are kept)`,
|
|
1133
|
+
});
|
|
1134
|
+
const presets = effectPresetNames(effect);
|
|
1135
|
+
if (presets.length > 0)
|
|
1136
|
+
nodes.push({
|
|
1137
|
+
kind: "choice",
|
|
1138
|
+
label: "preset",
|
|
1139
|
+
value: "—",
|
|
1140
|
+
options: presets,
|
|
1141
|
+
command: (preset) => `fx ${effect} preset ${preset}`,
|
|
1142
|
+
help: "a starting point; every value stays editable",
|
|
1143
|
+
});
|
|
1144
|
+
if (effect === "reverb") {
|
|
1145
|
+
// Convolution (Strudel `ir`): a generated impulse, or `fx ir <file>`.
|
|
1146
|
+
const ir = track.reverb?.ir?.src;
|
|
1147
|
+
const current = ir?.startsWith("builtin:")
|
|
1148
|
+
? ir.slice("builtin:".length)
|
|
1149
|
+
: (ir ?? "off");
|
|
1150
|
+
nodes.push({
|
|
1151
|
+
kind: "choice",
|
|
1152
|
+
label: "impulse (ir)",
|
|
1153
|
+
value: current,
|
|
1154
|
+
options: ["off", ...REVERB_IR_BUILTINS],
|
|
1155
|
+
command: (choice) => `fx reverb ir ${choice}`,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const keys = advanced ? Object.keys(spec.params) : spec.simple;
|
|
1160
|
+
for (const key of keys) {
|
|
1161
|
+
const param = spec.params[key]!;
|
|
1162
|
+
const current = values?.[key];
|
|
1163
|
+
const label =
|
|
1164
|
+
advanced && param.strudel?.length
|
|
1165
|
+
? `${key} (${param.strudel.filter((name) => !name.includes(" ")).join("/")})`
|
|
1166
|
+
: key;
|
|
1167
|
+
if (param.kind === "number") {
|
|
1168
|
+
nodes.push({
|
|
1169
|
+
kind: "number",
|
|
1170
|
+
label,
|
|
1171
|
+
value: typeof current === "number" ? current : undefined,
|
|
1172
|
+
start: param.default,
|
|
1173
|
+
off: values
|
|
1174
|
+
? withUnit(formatParam(param, param.default), param.unit)
|
|
1175
|
+
: "off",
|
|
1176
|
+
min: param.min,
|
|
1177
|
+
max: param.max,
|
|
1178
|
+
step: specStep(param),
|
|
1179
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
1180
|
+
command: (value) => `fx ${effect} ${key} ${formatParam(param, value)}`,
|
|
1181
|
+
help: param.doc,
|
|
1182
|
+
...(values
|
|
1183
|
+
? {
|
|
1184
|
+
reset: `fx ${effect} ${key} ${formatParam(param, param.default)}`,
|
|
1185
|
+
}
|
|
1186
|
+
: {}),
|
|
1187
|
+
});
|
|
1188
|
+
} else if (param.kind === "enum") {
|
|
1189
|
+
nodes.push({
|
|
1190
|
+
kind: "choice",
|
|
1191
|
+
label,
|
|
1192
|
+
value: typeof current === "string" ? current : param.default,
|
|
1193
|
+
options: param.values,
|
|
1194
|
+
command: (option) => `fx ${effect} ${key} ${option}`,
|
|
1195
|
+
help: param.doc,
|
|
1196
|
+
});
|
|
1197
|
+
} else {
|
|
1198
|
+
nodes.push({
|
|
1199
|
+
kind: "toggle",
|
|
1200
|
+
label,
|
|
1201
|
+
value: typeof current === "boolean" ? current : param.default,
|
|
1202
|
+
command: (on) => `fx ${effect} ${key} ${on ? "on" : "off"}`,
|
|
1203
|
+
help: param.doc,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (!advanced) {
|
|
1208
|
+
nodes.push({
|
|
1209
|
+
kind: "menu",
|
|
1210
|
+
id: `${effect}:advanced`,
|
|
1211
|
+
label: "advanced",
|
|
1212
|
+
detail: `all ${Object.keys(spec.params).length} params`,
|
|
1213
|
+
help: `Strudel: ${spec.strudel}`,
|
|
1214
|
+
build: (inner) => effectParamNodes(inner, effect, true),
|
|
1215
|
+
});
|
|
1216
|
+
if (values)
|
|
1217
|
+
nodes.push({
|
|
1218
|
+
kind: "action",
|
|
1219
|
+
label: "reset to defaults",
|
|
1220
|
+
command: `fx ${effect} reset`,
|
|
1221
|
+
help: `put every ${spec.label} value back to its default`,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
return nodes;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
492
1227
|
function automationNodes(context: MenuContext): MenuNode[] {
|
|
493
1228
|
const track = focused(context);
|
|
494
1229
|
if (!track) return [];
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
);
|
|
1230
|
+
// Track lanes, the lanes of effects that are on, and any lane with
|
|
1231
|
+
// points; every other effect lane is under "all lanes".
|
|
1232
|
+
const shown = AUTOMATION_PARAMETERS.filter((lane) => {
|
|
1233
|
+
if (isTrackAutomationParameter(lane)) return true;
|
|
1234
|
+
if (automationPoints(track, lane).length > 0) return true;
|
|
1235
|
+
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
1236
|
+
if (info?.effect === "synth")
|
|
1237
|
+
return track.synth?.[info.param] !== undefined;
|
|
1238
|
+
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
1239
|
+
});
|
|
1240
|
+
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
1241
|
+
const nodes = shown.map((lane) => laneMenu(track, lane));
|
|
1242
|
+
if (hidden.length > 0)
|
|
1243
|
+
nodes.push({
|
|
1244
|
+
kind: "menu",
|
|
1245
|
+
id: "lanes:all",
|
|
1246
|
+
label: "all lanes",
|
|
1247
|
+
detail: `${hidden.length} more effect lanes`,
|
|
1248
|
+
build: (inner) => {
|
|
1249
|
+
const current = focused(inner);
|
|
1250
|
+
return current ? hidden.map((lane) => laneMenu(current, lane)) : [];
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
return nodes;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function laneMenu(track: Track, lane: AutomationParameter): MenuNode {
|
|
1257
|
+
const count = automationPoints(track, lane).length;
|
|
1258
|
+
return {
|
|
1259
|
+
kind: "menu",
|
|
1260
|
+
id: `lane:${lane}`,
|
|
1261
|
+
label: laneLabel(lane),
|
|
1262
|
+
detail: `${count} point${count === 1 ? "" : "s"}`,
|
|
1263
|
+
build: (inner) => laneNodes(inner, lane),
|
|
1264
|
+
};
|
|
507
1265
|
}
|
|
508
1266
|
|
|
509
1267
|
/** `4:0.5 8:1` → `automate <lane> points 4:0.5 8:1`, when every pair parses. */
|
|
@@ -518,7 +1276,7 @@ function pointsCommand(
|
|
|
518
1276
|
.filter(Boolean);
|
|
519
1277
|
if (pairs.length === 0 || (exactly !== undefined && pairs.length !== exactly))
|
|
520
1278
|
return undefined;
|
|
521
|
-
const { min, max } =
|
|
1279
|
+
const { min, max } = automationRange(lane);
|
|
522
1280
|
for (const pair of pairs) {
|
|
523
1281
|
const match = pair.match(/^(\d+(?:\.\d+)?):(-?\d+(?:\.\d+)?)$/);
|
|
524
1282
|
if (!match) return undefined;
|
|
@@ -534,9 +1292,9 @@ function laneNodes(
|
|
|
534
1292
|
): MenuNode[] {
|
|
535
1293
|
const track = focused(context);
|
|
536
1294
|
if (!track) return [];
|
|
537
|
-
const { min, max
|
|
1295
|
+
const { min, max } = automationRange(lane);
|
|
538
1296
|
const range = `${laneFormat(lane, min)}…${laneFormat(lane, max)}`;
|
|
539
|
-
const points = track
|
|
1297
|
+
const points = automationPoints(track, lane);
|
|
540
1298
|
const nodes: MenuNode[] = [
|
|
541
1299
|
{
|
|
542
1300
|
kind: "entry",
|
|
@@ -592,6 +1350,134 @@ function mixNodes(context: MenuContext): MenuNode[] {
|
|
|
592
1350
|
});
|
|
593
1351
|
}
|
|
594
1352
|
|
|
1353
|
+
// ── sounds (sample packs) ──────────────────────────────────────────────
|
|
1354
|
+
|
|
1355
|
+
function soundsDetail(track: Track | undefined): string {
|
|
1356
|
+
const packs = new Set<string>();
|
|
1357
|
+
for (const ref of Object.values(track?.sampler?.voices ?? {}))
|
|
1358
|
+
if (ref.src.startsWith("pack:")) packs.add(ref.src.slice(5).split("/")[0]!);
|
|
1359
|
+
return packs.size ? [...packs].join(" · ") : "kits, instruments, packs";
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* The instrument browser: drum kits and soundfont instruments from the
|
|
1364
|
+
* built-in packs (fetched on first use), and the pack list.
|
|
1365
|
+
*/
|
|
1366
|
+
function kitsMenu(): MenuNode {
|
|
1367
|
+
return {
|
|
1368
|
+
kind: "menu",
|
|
1369
|
+
id: "kits",
|
|
1370
|
+
label: "drum kits",
|
|
1371
|
+
help: "make this a drum track with a synth or sample kit",
|
|
1372
|
+
detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
|
|
1373
|
+
// Synth kits first (offline), then the pack sample kits.
|
|
1374
|
+
build: () => [
|
|
1375
|
+
...kitCatalog().map((entry): MenuNode => ({
|
|
1376
|
+
kind: "action",
|
|
1377
|
+
label:
|
|
1378
|
+
entry.kind === "synth"
|
|
1379
|
+
? `${entry.label} · ${entry.detail}`
|
|
1380
|
+
: `${entry.label} · ${DEFAULT_KITS[entry.name]?.pack ?? entry.detail}`,
|
|
1381
|
+
command: entry.command,
|
|
1382
|
+
})),
|
|
1383
|
+
{
|
|
1384
|
+
kind: "menu",
|
|
1385
|
+
id: "kit-nicknames",
|
|
1386
|
+
label: "Strudel banks",
|
|
1387
|
+
detail: `${Object.keys(STRUDEL_BANK_ALIASES).length} drum machines by nickname`,
|
|
1388
|
+
build: () =>
|
|
1389
|
+
Object.entries(STRUDEL_BANK_ALIASES)
|
|
1390
|
+
.sort(([, a], [, b]) =>
|
|
1391
|
+
a.toLowerCase() < b.toLowerCase() ? -1 : 1,
|
|
1392
|
+
)
|
|
1393
|
+
.map(([bank, nickname]): MenuNode => ({
|
|
1394
|
+
kind: "action",
|
|
1395
|
+
label: `${nickname} ${bank}`,
|
|
1396
|
+
command: `/kit ${nickname}`,
|
|
1397
|
+
})),
|
|
1398
|
+
},
|
|
1399
|
+
],
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function patternsMenu(): MenuNode {
|
|
1404
|
+
return {
|
|
1405
|
+
kind: "menu",
|
|
1406
|
+
id: "patterns",
|
|
1407
|
+
label: "drum patterns",
|
|
1408
|
+
detail: `${DRUM_PATTERNS.length} grooves`,
|
|
1409
|
+
help: "replace the drum part with a ready-made groove",
|
|
1410
|
+
build: () =>
|
|
1411
|
+
DRUM_PATTERNS.map((entry): MenuNode => ({
|
|
1412
|
+
kind: "action",
|
|
1413
|
+
label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
1414
|
+
command: `/pattern ${entry.name}`,
|
|
1415
|
+
})),
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function soundNodes(): MenuNode[] {
|
|
1420
|
+
return [
|
|
1421
|
+
{
|
|
1422
|
+
kind: "action",
|
|
1423
|
+
label: "wavetable synth basic shapes morph · built-in",
|
|
1424
|
+
command: "wt basic",
|
|
1425
|
+
help: "switch this track to the morphing wavetable synth",
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
kind: "menu",
|
|
1429
|
+
id: "instruments",
|
|
1430
|
+
label: "instruments",
|
|
1431
|
+
help: "sampled piano and General MIDI instruments",
|
|
1432
|
+
detail: "General MIDI soundfont, piano",
|
|
1433
|
+
build: () => [
|
|
1434
|
+
{
|
|
1435
|
+
kind: "action",
|
|
1436
|
+
label: "piano Salamander grand · piano",
|
|
1437
|
+
command: "/pack use piano/piano",
|
|
1438
|
+
},
|
|
1439
|
+
...GM_INSTRUMENTS.map((name): MenuNode => ({
|
|
1440
|
+
kind: "action",
|
|
1441
|
+
label: `${name.replace(/^gm_/, "").replace(/_/g, " ")} · gm`,
|
|
1442
|
+
command: `/pack use gm/${name}`,
|
|
1443
|
+
})),
|
|
1444
|
+
],
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
kind: "entry",
|
|
1448
|
+
label: "use a sound",
|
|
1449
|
+
value: "",
|
|
1450
|
+
help: "type a pack sound name, e.g. dirt-samples/bd:3",
|
|
1451
|
+
placeholder: "<pack>/<sound>[:<n>]",
|
|
1452
|
+
command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
|
|
1453
|
+
example: "/pack use dirt-samples/bd:3",
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
kind: "menu",
|
|
1457
|
+
id: "packs",
|
|
1458
|
+
label: "sample packs",
|
|
1459
|
+
help: "browse built-in packs or add one by URL",
|
|
1460
|
+
detail: `${PACK_CATALOG.length} built in`,
|
|
1461
|
+
build: () => [
|
|
1462
|
+
...PACK_CATALOG.map((pack): MenuNode => ({
|
|
1463
|
+
kind: "action",
|
|
1464
|
+
label: `${pack.name} ${pack.license}`,
|
|
1465
|
+
command: `/pack info ${pack.name}`,
|
|
1466
|
+
})),
|
|
1467
|
+
{
|
|
1468
|
+
kind: "entry",
|
|
1469
|
+
label: "add a pack",
|
|
1470
|
+
value: "",
|
|
1471
|
+
placeholder: "manifest URL or github:user/repo",
|
|
1472
|
+
command: (text) =>
|
|
1473
|
+
text.trim() ? `/pack add ${text.trim()}` : undefined,
|
|
1474
|
+
example: "/pack add github:yaxu/clean-breaks",
|
|
1475
|
+
},
|
|
1476
|
+
],
|
|
1477
|
+
},
|
|
1478
|
+
];
|
|
1479
|
+
}
|
|
1480
|
+
|
|
595
1481
|
function transportNodes(context: MenuContext): MenuNode[] {
|
|
596
1482
|
const score = context.score;
|
|
597
1483
|
return [
|
|
@@ -599,20 +1485,23 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
599
1485
|
kind: "action",
|
|
600
1486
|
label: context.playing ? "pause" : "play",
|
|
601
1487
|
command: context.playing ? "pause" : "play",
|
|
1488
|
+
help: "start or stop the loop (space on an empty prompt)",
|
|
602
1489
|
},
|
|
603
1490
|
{
|
|
604
1491
|
kind: "number",
|
|
605
|
-
label: "tempo
|
|
1492
|
+
label: "tempo",
|
|
1493
|
+
help: "beats per minute",
|
|
606
1494
|
value: score.tempoBpm,
|
|
607
1495
|
min: SCORE_LIMITS.minTempoBpm,
|
|
608
1496
|
max: SCORE_LIMITS.maxTempoBpm,
|
|
609
1497
|
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
610
|
-
format: num
|
|
1498
|
+
format: (value) => `${num(value)} BPM`,
|
|
611
1499
|
command: (value) => `tempo ${num(value)}`,
|
|
612
1500
|
},
|
|
613
1501
|
{
|
|
614
1502
|
kind: "number",
|
|
615
1503
|
label: "beats per bar",
|
|
1504
|
+
help: "the meter",
|
|
616
1505
|
value: score.beatsPerBar,
|
|
617
1506
|
min: 1,
|
|
618
1507
|
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
@@ -622,17 +1511,19 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
622
1511
|
},
|
|
623
1512
|
{
|
|
624
1513
|
kind: "number",
|
|
625
|
-
label: "loop
|
|
1514
|
+
label: "loop length",
|
|
1515
|
+
help: "how long the loop is",
|
|
626
1516
|
value: score.bars,
|
|
627
1517
|
min: 1,
|
|
628
1518
|
max: SCORE_LIMITS.maxBars,
|
|
629
1519
|
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
630
|
-
format: num
|
|
1520
|
+
format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
|
|
631
1521
|
command: (value) => `bars ${Math.round(value)}`,
|
|
632
1522
|
},
|
|
633
1523
|
{
|
|
634
1524
|
kind: "choice",
|
|
635
1525
|
label: "grid",
|
|
1526
|
+
help: "the step play-mode recording and the euclid editor snap to",
|
|
636
1527
|
value: context.grid,
|
|
637
1528
|
options: context.grids,
|
|
638
1529
|
command: (option) => `/grid ${option}`,
|
|
@@ -640,12 +1531,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
640
1531
|
{
|
|
641
1532
|
kind: "toggle",
|
|
642
1533
|
label: "click",
|
|
1534
|
+
help: "metronome while playing and recording",
|
|
643
1535
|
value: context.clickOn,
|
|
644
1536
|
command: (on) => `/click ${on ? "on" : "off"}`,
|
|
645
1537
|
},
|
|
646
1538
|
{
|
|
647
1539
|
kind: "choice",
|
|
648
1540
|
label: "count-in bars",
|
|
1541
|
+
help: "bars of click before recording starts",
|
|
649
1542
|
value: String(context.countInBars),
|
|
650
1543
|
options: ["0", "1", "2"],
|
|
651
1544
|
command: (option) => `/count-in ${option}`,
|
|
@@ -657,9 +1550,17 @@ function transportNodes(context: MenuContext): MenuNode[] {
|
|
|
657
1550
|
|
|
658
1551
|
type Frame = {
|
|
659
1552
|
title: string;
|
|
1553
|
+
/** The submenu's node id (`chords`), for `EditMenu.section`. */
|
|
1554
|
+
id?: string;
|
|
660
1555
|
build: (context: MenuContext) => MenuNode[];
|
|
661
1556
|
index: number;
|
|
662
1557
|
query: string;
|
|
1558
|
+
/**
|
|
1559
|
+
* Lists audition the item under the cursor while the loop plays: the
|
|
1560
|
+
* hover key, and whether this frame has staged a hover yet.
|
|
1561
|
+
*/
|
|
1562
|
+
hover?: string;
|
|
1563
|
+
hovering?: boolean;
|
|
663
1564
|
};
|
|
664
1565
|
|
|
665
1566
|
const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
|
|
@@ -681,8 +1582,10 @@ function childFrame(
|
|
|
681
1582
|
): Frame {
|
|
682
1583
|
return {
|
|
683
1584
|
title: node.label,
|
|
1585
|
+
id: node.id,
|
|
684
1586
|
index: 0,
|
|
685
1587
|
query: "",
|
|
1588
|
+
hover: `menu:${node.id}`,
|
|
686
1589
|
build: (context) => {
|
|
687
1590
|
const fresh = parent
|
|
688
1591
|
.build(context)
|
|
@@ -705,21 +1608,34 @@ export class EditMenu {
|
|
|
705
1608
|
return this.stack.length > 0;
|
|
706
1609
|
}
|
|
707
1610
|
|
|
1611
|
+
/** True while the menu takes typed text (a filter or a value). */
|
|
1612
|
+
get typing(): boolean {
|
|
1613
|
+
return this.filtering || this.entry !== undefined;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** The root section open now (`chords`), or undefined at the root. */
|
|
1617
|
+
get section(): string | undefined {
|
|
1618
|
+
return this.stack[1]?.id;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
708
1621
|
/** Open at the root, or at a section id (`effects`, `automation`). */
|
|
709
1622
|
show(context: MenuContext, section?: string): void {
|
|
710
1623
|
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
711
1624
|
this.filtering = false;
|
|
712
1625
|
this.entry = undefined;
|
|
713
|
-
|
|
714
|
-
|
|
1626
|
+
// Walk the section path (`automation` is Mix & automation › automation).
|
|
1627
|
+
for (const id of section
|
|
1628
|
+
? (SECTION_ALIASES[section.toLowerCase()] ?? [])
|
|
1629
|
+
: []) {
|
|
1630
|
+
const frame = this.stack.at(-1)!;
|
|
1631
|
+
const nodes = frame.build(context);
|
|
715
1632
|
const index = nodes.findIndex(
|
|
716
|
-
(node) => node.kind === "menu" && node.id ===
|
|
1633
|
+
(node) => node.kind === "menu" && node.id === id,
|
|
717
1634
|
);
|
|
718
1635
|
const node = nodes[index];
|
|
719
|
-
if (node?.kind
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
1636
|
+
if (node?.kind !== "menu") break;
|
|
1637
|
+
frame.index = index;
|
|
1638
|
+
this.stack.push(childFrame(frame, node));
|
|
723
1639
|
}
|
|
724
1640
|
}
|
|
725
1641
|
|
|
@@ -787,6 +1703,13 @@ export class EditMenu {
|
|
|
787
1703
|
frame.query = "";
|
|
788
1704
|
return { type: "handled" };
|
|
789
1705
|
}
|
|
1706
|
+
// In a list, Esc drops the hovered item and goes back; elsewhere it
|
|
1707
|
+
// reverts staged edits first, then goes back as usual.
|
|
1708
|
+
if (frame.hover && frame.hovering && context.audition) {
|
|
1709
|
+
this.stack.pop();
|
|
1710
|
+
return { type: "unhover", key: frame.hover };
|
|
1711
|
+
}
|
|
1712
|
+
if (context.audition?.dirty) return { type: "revert" };
|
|
790
1713
|
this.stack.pop();
|
|
791
1714
|
return this.stack.length ? { type: "handled" } : { type: "close" };
|
|
792
1715
|
}
|
|
@@ -799,21 +1722,43 @@ export class EditMenu {
|
|
|
799
1722
|
const step = KEY_UP.has(value) ? -1 : 1;
|
|
800
1723
|
if (nodes.length)
|
|
801
1724
|
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
802
|
-
return
|
|
1725
|
+
return this.hovered(frame, context);
|
|
803
1726
|
}
|
|
804
1727
|
if (value === "\u001b[5~" || value === "\u001b[H") {
|
|
805
1728
|
frame.index = 0;
|
|
806
|
-
return
|
|
1729
|
+
return this.hovered(frame, context);
|
|
807
1730
|
}
|
|
808
1731
|
if (value === "\u001b[6~" || value === "\u001b[F") {
|
|
809
1732
|
frame.index = Math.max(0, nodes.length - 1);
|
|
810
|
-
return
|
|
1733
|
+
return this.hovered(frame, context);
|
|
811
1734
|
}
|
|
1735
|
+
// Space hears the focused track (it still switches a toggle row);
|
|
1736
|
+
// `a` and `c` are A/B and context while the window hosts an audition.
|
|
1737
|
+
const audition = context.audition ? auditionKey(value) : undefined;
|
|
1738
|
+
if (audition && !(audition === "loop" && node?.kind === "toggle"))
|
|
1739
|
+
return { type: "audition", key: audition };
|
|
812
1740
|
if (!node) return { type: "handled" };
|
|
1741
|
+
if (
|
|
1742
|
+
KEY_ENTER.has(value) &&
|
|
1743
|
+
context.audition?.dirty &&
|
|
1744
|
+
(node.kind === "number" ||
|
|
1745
|
+
node.kind === "toggle" ||
|
|
1746
|
+
node.kind === "point" ||
|
|
1747
|
+
node.kind === "info")
|
|
1748
|
+
)
|
|
1749
|
+
return { type: "keep" };
|
|
813
1750
|
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
814
1751
|
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
815
1752
|
return this.nudge(node, direction);
|
|
816
1753
|
}
|
|
1754
|
+
if (KEY_DELETE.has(value) && node.kind === "number") {
|
|
1755
|
+
const reset =
|
|
1756
|
+
node.reset ??
|
|
1757
|
+
(node.start !== undefined && node.value !== undefined
|
|
1758
|
+
? node.command(node.start)
|
|
1759
|
+
: undefined);
|
|
1760
|
+
return reset ? { type: "run", command: reset } : { type: "handled" };
|
|
1761
|
+
}
|
|
817
1762
|
if (KEY_DELETE.has(value) && node.kind === "point")
|
|
818
1763
|
return {
|
|
819
1764
|
type: "run",
|
|
@@ -844,13 +1789,19 @@ export class EditMenu {
|
|
|
844
1789
|
}
|
|
845
1790
|
if (node.kind === "toggle")
|
|
846
1791
|
return { type: "run", command: node.command(!node.value) };
|
|
847
|
-
if (node.kind === "action")
|
|
1792
|
+
if (node.kind === "action")
|
|
1793
|
+
return frame.hover &&
|
|
1794
|
+
context.audition?.looping &&
|
|
1795
|
+
(context.audition.stageable ?? isStageable)(node.command)
|
|
1796
|
+
? { type: "choose", command: node.command, key: frame.hover }
|
|
1797
|
+
: { type: "run", command: node.command };
|
|
848
1798
|
if (node.kind === "choice") {
|
|
849
1799
|
const current = node.options.indexOf(node.value);
|
|
850
1800
|
this.stack.push({
|
|
851
1801
|
title: node.label,
|
|
852
1802
|
index: Math.max(0, current),
|
|
853
1803
|
query: "",
|
|
1804
|
+
hover: node.label,
|
|
854
1805
|
build: (fresh) => {
|
|
855
1806
|
const parent = this.stack.at(-2);
|
|
856
1807
|
const live =
|
|
@@ -882,6 +1833,17 @@ export class EditMenu {
|
|
|
882
1833
|
return { type: "handled" };
|
|
883
1834
|
}
|
|
884
1835
|
|
|
1836
|
+
/** After a move: audition the item under the cursor in a list. */
|
|
1837
|
+
private hovered(frame: Frame, context: MenuContext): MenuResult {
|
|
1838
|
+
if (!frame.hover || !context.audition?.looping) return { type: "handled" };
|
|
1839
|
+
const node = this.selected(context);
|
|
1840
|
+
const stageable = context.audition?.stageable ?? isStageable;
|
|
1841
|
+
if (node?.kind !== "action" || !stageable(node.command))
|
|
1842
|
+
return { type: "handled" };
|
|
1843
|
+
frame.hovering = true;
|
|
1844
|
+
return { type: "hover", command: node.command, key: frame.hover };
|
|
1845
|
+
}
|
|
1846
|
+
|
|
885
1847
|
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
886
1848
|
if (node.kind === "menu") {
|
|
887
1849
|
if (direction > 0) {
|
|
@@ -901,8 +1863,8 @@ export class EditMenu {
|
|
|
901
1863
|
return { type: "run", command: node.command(next) };
|
|
902
1864
|
}
|
|
903
1865
|
if (node.kind === "point") {
|
|
904
|
-
const { min, max } =
|
|
905
|
-
const next = clamp(
|
|
1866
|
+
const { min, max } = automationRange(node.lane);
|
|
1867
|
+
const next = clamp(laneStep(node.lane)(node.value, direction), min, max);
|
|
906
1868
|
if (Math.abs(next - node.value) < 1e-9) return { type: "handled" };
|
|
907
1869
|
return {
|
|
908
1870
|
type: "run",
|
|
@@ -923,7 +1885,11 @@ export class EditMenu {
|
|
|
923
1885
|
? { type: "handled" }
|
|
924
1886
|
: { type: "run", command: node.command(next) };
|
|
925
1887
|
}
|
|
926
|
-
if (direction < 0 && this.stack.length > 1)
|
|
1888
|
+
if (direction < 0 && this.stack.length > 1) {
|
|
1889
|
+
const left = this.stack.pop();
|
|
1890
|
+
if (left?.hover && left.hovering)
|
|
1891
|
+
return { type: "unhover", key: left.hover };
|
|
1892
|
+
}
|
|
927
1893
|
return { type: "handled" };
|
|
928
1894
|
}
|
|
929
1895
|
|
|
@@ -973,23 +1939,78 @@ export class EditMenu {
|
|
|
973
1939
|
if (frame?.query || this.filtering) title += ` · /${frame?.query ?? ""}`;
|
|
974
1940
|
if (this.entry)
|
|
975
1941
|
title += ` · ${this.entry.label}: ${this.entry.buffer || placeholderFor(selected)}▏`;
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1942
|
+
const audition = context.audition;
|
|
1943
|
+
if (audition?.dirty) title = `● ${title}`;
|
|
1944
|
+
if (audition?.status && !this.entry) title += ` · ${audition.status}`;
|
|
1945
|
+
// Changed rows show `staged ← committed` (Elektron's compare, inline).
|
|
1946
|
+
const before = new Map<string, string>();
|
|
1947
|
+
if (audition?.dirty && frame && !frame.query)
|
|
1948
|
+
for (const node of frame.build({
|
|
1949
|
+
...context,
|
|
1950
|
+
score: audition.committed,
|
|
1951
|
+
...(audition.committedChords
|
|
1952
|
+
? { chords: audition.committedChords }
|
|
1953
|
+
: {}),
|
|
1954
|
+
}))
|
|
1955
|
+
before.set(node.label, valueText(node));
|
|
1956
|
+
const items = nodes.map((node, at) => {
|
|
1957
|
+
const now = valueText(node);
|
|
1958
|
+
const was = node.kind === "menu" ? undefined : before.get(node.label);
|
|
1959
|
+
const shown = was !== undefined && was !== now ? `${now} ← ${was}` : now;
|
|
1960
|
+
return {
|
|
1961
|
+
label: `${node.label.padEnd(LABEL_WIDTH)} ${shown}`.trimEnd(),
|
|
1962
|
+
value: String(at),
|
|
1963
|
+
};
|
|
1964
|
+
});
|
|
1965
|
+
const command = selected ? commandText(selected) : undefined;
|
|
1966
|
+
const note = [
|
|
1967
|
+
selected ? describe(selected) : "",
|
|
1968
|
+
selected?.kind === "number" && command
|
|
1969
|
+
? rowSketch(selected, command, nodes)
|
|
1970
|
+
: "",
|
|
1971
|
+
command ? `› ${command}` : "",
|
|
1972
|
+
]
|
|
1973
|
+
.filter(Boolean)
|
|
1974
|
+
.join(" ");
|
|
981
1975
|
const hint = this.entry
|
|
982
|
-
?
|
|
1976
|
+
? HINTS.typing
|
|
983
1977
|
: this.filtering
|
|
984
|
-
?
|
|
985
|
-
: selected?.kind === "
|
|
986
|
-
?
|
|
987
|
-
:
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1978
|
+
? HINTS.filtering
|
|
1979
|
+
: audition?.looping && frame?.hover && selected?.kind === "action"
|
|
1980
|
+
? HINTS.hover
|
|
1981
|
+
: audition && (audition.looping || audition.dirty)
|
|
1982
|
+
? audition.hint
|
|
1983
|
+
: selected?.kind === "point"
|
|
1984
|
+
? HINTS.point
|
|
1985
|
+
: selected?.kind === "number" ||
|
|
1986
|
+
selected?.kind === "toggle" ||
|
|
1987
|
+
selected?.kind === "choice"
|
|
1988
|
+
? HINTS.value
|
|
1989
|
+
: selected?.kind === "action"
|
|
1990
|
+
? HINTS.action
|
|
1991
|
+
: HINTS.menu;
|
|
1992
|
+
return { title, items, index, hint, note };
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** The focused row's one-line description. */
|
|
1997
|
+
function describe(node: MenuNode): string {
|
|
1998
|
+
if (node.help) return node.help;
|
|
1999
|
+
switch (node.kind) {
|
|
2000
|
+
case "number":
|
|
2001
|
+
return node.reset || node.start !== undefined
|
|
2002
|
+
? "←→ adjust · type a number · x resets"
|
|
2003
|
+
: "←→ adjust · type a number";
|
|
2004
|
+
case "toggle":
|
|
2005
|
+
return "space or enter switches it";
|
|
2006
|
+
case "choice":
|
|
2007
|
+
return `${node.options.length} choices · enter lists them`;
|
|
2008
|
+
case "point":
|
|
2009
|
+
return "←→ change · type beat:value · x deletes";
|
|
2010
|
+
case "entry":
|
|
2011
|
+
return "enter, then type";
|
|
2012
|
+
default:
|
|
2013
|
+
return "";
|
|
993
2014
|
}
|
|
994
2015
|
}
|
|
995
2016
|
|
|
@@ -1013,7 +2034,7 @@ function entryCommand(node: MenuNode, text: string): string | undefined {
|
|
|
1013
2034
|
}
|
|
1014
2035
|
if (node.kind === "point") {
|
|
1015
2036
|
// `0.8` sets the value; `6:0.8` moves to beat 6.
|
|
1016
|
-
const { min, max } =
|
|
2037
|
+
const { min, max } = automationRange(node.lane);
|
|
1017
2038
|
const match = trimmed.match(/^(?:(\d+(?:\.\d+)?):)?(-?\d+(?:\.\d+)?)$/);
|
|
1018
2039
|
if (!match) return undefined;
|
|
1019
2040
|
const value = Number(match[2]);
|
|
@@ -1049,6 +2070,40 @@ function valueText(node: MenuNode): string {
|
|
|
1049
2070
|
}
|
|
1050
2071
|
|
|
1051
2072
|
/** The command a row stands for, shown so the menu teaches commands. */
|
|
2073
|
+
/** A filter curve, envelope or table position for a focused number row. */
|
|
2074
|
+
function rowSketch(
|
|
2075
|
+
node: Extract<MenuNode, { kind: "number" }>,
|
|
2076
|
+
command: string,
|
|
2077
|
+
nodes: readonly MenuNode[],
|
|
2078
|
+
): string {
|
|
2079
|
+
const value = node.value ?? node.start ?? node.min;
|
|
2080
|
+
return (
|
|
2081
|
+
sketchFor(
|
|
2082
|
+
command,
|
|
2083
|
+
value,
|
|
2084
|
+
(param) => {
|
|
2085
|
+
for (const sibling of nodes) {
|
|
2086
|
+
if (sibling.kind !== "number") continue;
|
|
2087
|
+
const text = commandText(sibling);
|
|
2088
|
+
if (text && commandParam(text) === param)
|
|
2089
|
+
return sibling.value ?? sibling.start;
|
|
2090
|
+
}
|
|
2091
|
+
return undefined;
|
|
2092
|
+
},
|
|
2093
|
+
filterTypeOf(nodes),
|
|
2094
|
+
) ?? ""
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function filterTypeOf(nodes: readonly MenuNode[]): string | undefined {
|
|
2099
|
+
const row = nodes.find(
|
|
2100
|
+
(node) => node.kind === "choice" && node.label === "type",
|
|
2101
|
+
);
|
|
2102
|
+
return row?.kind === "choice" && typeof row.value === "string"
|
|
2103
|
+
? row.value
|
|
2104
|
+
: undefined;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1052
2107
|
function commandText(node: MenuNode): string | undefined {
|
|
1053
2108
|
switch (node.kind) {
|
|
1054
2109
|
case "number":
|
|
@@ -1074,10 +2129,16 @@ function nodeText(node: MenuNode): string {
|
|
|
1074
2129
|
|
|
1075
2130
|
/** Root sections, for `/menu <section>`. */
|
|
1076
2131
|
export const MENU_SECTIONS = [
|
|
1077
|
-
"
|
|
1078
|
-
"parameters",
|
|
2132
|
+
"sound",
|
|
1079
2133
|
"effects",
|
|
1080
|
-
"
|
|
2134
|
+
"rhythm",
|
|
2135
|
+
"chords",
|
|
1081
2136
|
"mix",
|
|
2137
|
+
"project",
|
|
2138
|
+
// Older names, still accepted.
|
|
2139
|
+
"parameters",
|
|
2140
|
+
"sounds",
|
|
2141
|
+
"track",
|
|
2142
|
+
"automation",
|
|
1082
2143
|
"transport",
|
|
1083
2144
|
] as const;
|