@hraness/dawg 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
package/src/tui/menu.ts
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-editing menu (`/menu`, Ctrl-K): every edit the agent can make,
|
|
3
|
+
* reachable with arrow keys. Pure: the tree is rebuilt from the score on
|
|
4
|
+
* every key, and a change is returned as the command it stands for, which
|
|
5
|
+
* the caller runs through the normal prompt path (one ScoreOperation, one
|
|
6
|
+
* receipt, one undo step). Each row shows its current value and that
|
|
7
|
+
* command, so the menu teaches the commands.
|
|
8
|
+
*
|
|
9
|
+
* Rendering reuses the TUI's picker overlay: `view()` returns a picker.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
AUTOMATION_LANES,
|
|
13
|
+
SCORE_LIMITS,
|
|
14
|
+
type AutomationParameter,
|
|
15
|
+
type Track,
|
|
16
|
+
type TrackScore,
|
|
17
|
+
} from "../../core/score.ts";
|
|
18
|
+
import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
19
|
+
import type { PickerItem } from "../../tui/app.ts";
|
|
20
|
+
|
|
21
|
+
/** What the menu needs to know beyond the score. */
|
|
22
|
+
export type MenuContext = Readonly<{
|
|
23
|
+
score: TrackScore;
|
|
24
|
+
trackId: string;
|
|
25
|
+
playing: boolean;
|
|
26
|
+
grid: string;
|
|
27
|
+
grids: readonly string[];
|
|
28
|
+
clickOn: boolean;
|
|
29
|
+
countInBars: number;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
type NumberField = Readonly<{
|
|
33
|
+
kind: "number";
|
|
34
|
+
label: string;
|
|
35
|
+
/** `undefined` while the effect is off; a nudge then turns it on. */
|
|
36
|
+
value: number | undefined;
|
|
37
|
+
min: number;
|
|
38
|
+
max: number;
|
|
39
|
+
/** Next value one step in `direction`. */
|
|
40
|
+
step: (value: number, direction: 1 | -1) => number;
|
|
41
|
+
format: (value: number) => string;
|
|
42
|
+
command: (value: number) => string;
|
|
43
|
+
/** Shown while `value` is undefined. */
|
|
44
|
+
off?: string;
|
|
45
|
+
/** Starting value for a nudge while off. */
|
|
46
|
+
start?: number;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
export type MenuNode =
|
|
50
|
+
| Readonly<{
|
|
51
|
+
kind: "menu";
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
detail: string;
|
|
55
|
+
build: (context: MenuContext) => MenuNode[];
|
|
56
|
+
}>
|
|
57
|
+
| NumberField
|
|
58
|
+
| Readonly<{
|
|
59
|
+
kind: "toggle";
|
|
60
|
+
label: string;
|
|
61
|
+
value: boolean;
|
|
62
|
+
command: (value: boolean) => string;
|
|
63
|
+
}>
|
|
64
|
+
| Readonly<{
|
|
65
|
+
kind: "choice";
|
|
66
|
+
label: string;
|
|
67
|
+
value: string;
|
|
68
|
+
options: readonly string[];
|
|
69
|
+
command: (option: string) => string;
|
|
70
|
+
}>
|
|
71
|
+
| Readonly<{
|
|
72
|
+
kind: "entry";
|
|
73
|
+
label: string;
|
|
74
|
+
value: string;
|
|
75
|
+
/** Shown in the title while typing. */
|
|
76
|
+
placeholder: string;
|
|
77
|
+
/** Command for the typed text; undefined when it cannot parse. */
|
|
78
|
+
command: (text: string) => string | undefined;
|
|
79
|
+
/** Shown as the slash-command equivalent. */
|
|
80
|
+
example: string;
|
|
81
|
+
}>
|
|
82
|
+
| Readonly<{ kind: "action"; label: string; command: string }>
|
|
83
|
+
| Readonly<{
|
|
84
|
+
kind: "point";
|
|
85
|
+
label: string;
|
|
86
|
+
lane: AutomationParameter;
|
|
87
|
+
beat: number;
|
|
88
|
+
value: number;
|
|
89
|
+
}>
|
|
90
|
+
| Readonly<{ kind: "info"; label: string; value: string }>;
|
|
91
|
+
|
|
92
|
+
/** What a key did; `run` carries the command to execute. */
|
|
93
|
+
export type MenuResult =
|
|
94
|
+
| { type: "handled" }
|
|
95
|
+
| { type: "close" }
|
|
96
|
+
| { type: "run"; command: string }
|
|
97
|
+
| { type: "pass" };
|
|
98
|
+
|
|
99
|
+
export type MenuView = Readonly<{
|
|
100
|
+
title: string;
|
|
101
|
+
items: PickerItem[];
|
|
102
|
+
index: number;
|
|
103
|
+
hint: string;
|
|
104
|
+
}>;
|
|
105
|
+
|
|
106
|
+
// ── formatting and steps ──────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function num(value: number): string {
|
|
109
|
+
return String(Math.round(value * 1000) / 1000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const linear =
|
|
113
|
+
(size: number, min: number, max: number) =>
|
|
114
|
+
(value: number, direction: 1 | -1) =>
|
|
115
|
+
clamp(Math.round((value + size * direction) / size) * size, min, max);
|
|
116
|
+
|
|
117
|
+
/** Cutoff moves by thirds of an octave-ish so the low end stays usable. */
|
|
118
|
+
const cutoffStep = (value: number, direction: 1 | -1) =>
|
|
119
|
+
clamp(
|
|
120
|
+
Math.round(direction > 0 ? value * 1.25 : value / 1.25),
|
|
121
|
+
SCORE_LIMITS.minFilterCutoff,
|
|
122
|
+
SCORE_LIMITS.maxFilterCutoff,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const DELAY_BEATS = [
|
|
126
|
+
0.0625, 0.125, 0.1875, 0.25, 0.375, 0.5, 0.75, 1, 1.5, 2, 3, 4,
|
|
127
|
+
] as const;
|
|
128
|
+
const delayStep = (value: number, direction: 1 | -1) => {
|
|
129
|
+
if (direction > 0)
|
|
130
|
+
return DELAY_BEATS.find((beats) => beats > value + 1e-9) ?? 4;
|
|
131
|
+
return (
|
|
132
|
+
[...DELAY_BEATS].reverse().find((beats) => beats < value - 1e-9) ?? 0.0625
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function clamp(value: number, min: number, max: number): number {
|
|
137
|
+
return Math.min(max, Math.max(min, value));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const LANE_STEP: Readonly<
|
|
141
|
+
Record<AutomationParameter, (value: number, direction: 1 | -1) => number>
|
|
142
|
+
> = {
|
|
143
|
+
volume: linear(0.05, 0, SCORE_LIMITS.maxVolume),
|
|
144
|
+
pan: linear(0.1, -1, 1),
|
|
145
|
+
filter: cutoffStep,
|
|
146
|
+
resonance: linear(0.05, 0, SCORE_LIMITS.maxFilterResonance),
|
|
147
|
+
"delay-feedback": linear(0.05, 0, SCORE_LIMITS.maxDelayFeedback),
|
|
148
|
+
"delay-mix": linear(0.05, 0, SCORE_LIMITS.maxDelayMix),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const LANE_LABEL: Readonly<Record<AutomationParameter, string>> = {
|
|
152
|
+
volume: "volume",
|
|
153
|
+
pan: "pan",
|
|
154
|
+
filter: "filter cutoff (Hz)",
|
|
155
|
+
resonance: "filter resonance",
|
|
156
|
+
"delay-feedback": "delay feedback",
|
|
157
|
+
"delay-mix": "delay mix",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** `volume 0.8`, `pan -0.3`: the command that sets a lane's static value. */
|
|
161
|
+
function laneFormat(lane: AutomationParameter, value: number): string {
|
|
162
|
+
return lane === "filter" ? `${Math.round(value)}` : num(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── the tree ──────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function focused(context: MenuContext): Track | undefined {
|
|
168
|
+
return context.score.tracks.find((track) => track.id === context.trackId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function rootNodes(context: MenuContext): MenuNode[] {
|
|
172
|
+
const track = focused(context);
|
|
173
|
+
const name = track?.name ?? context.trackId;
|
|
174
|
+
const effects = [
|
|
175
|
+
track?.filter ? "filter" : undefined,
|
|
176
|
+
track?.delay ? "delay" : undefined,
|
|
177
|
+
track?.reverb ? "reverb" : undefined,
|
|
178
|
+
].filter(Boolean);
|
|
179
|
+
const automated = (Object.keys(AUTOMATION_LANES) as AutomationParameter[])
|
|
180
|
+
.filter((lane) => (track?.[AUTOMATION_LANES[lane].field]?.length ?? 0) > 0)
|
|
181
|
+
.join(" ");
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
kind: "menu",
|
|
185
|
+
id: "track",
|
|
186
|
+
label: "Track",
|
|
187
|
+
detail: `${name} · ${track?.instrument ?? "?"}`,
|
|
188
|
+
build: trackNodes,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
kind: "menu",
|
|
192
|
+
id: "parameters",
|
|
193
|
+
label: "Parameters",
|
|
194
|
+
detail: `${track?.instrument ?? "?"}`,
|
|
195
|
+
build: parameterNodes,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
kind: "menu",
|
|
199
|
+
id: "effects",
|
|
200
|
+
label: "Effects",
|
|
201
|
+
detail: effects.length ? effects.join(" · ") : "none",
|
|
202
|
+
build: effectNodes,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
kind: "menu",
|
|
206
|
+
id: "automation",
|
|
207
|
+
label: "Automation",
|
|
208
|
+
detail: automated || "no lanes",
|
|
209
|
+
build: automationNodes,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
kind: "menu",
|
|
213
|
+
id: "mix",
|
|
214
|
+
label: "Mix",
|
|
215
|
+
detail: `${context.score.tracks.length} track${context.score.tracks.length === 1 ? "" : "s"}`,
|
|
216
|
+
build: mixNodes,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
kind: "menu",
|
|
220
|
+
id: "transport",
|
|
221
|
+
label: "Transport",
|
|
222
|
+
detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars · grid ${context.grid}`,
|
|
223
|
+
build: transportNodes,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function trackNodes(context: MenuContext): MenuNode[] {
|
|
229
|
+
const track = focused(context);
|
|
230
|
+
if (!track)
|
|
231
|
+
return [{ kind: "info", label: "no track", value: "/track <name>" }];
|
|
232
|
+
return [
|
|
233
|
+
{
|
|
234
|
+
kind: "entry",
|
|
235
|
+
label: "name",
|
|
236
|
+
value: track.name,
|
|
237
|
+
placeholder: "new name",
|
|
238
|
+
command: (text) =>
|
|
239
|
+
text.trim() ? `track name ${text.trim()}` : undefined,
|
|
240
|
+
example: `track name ${track.name}`,
|
|
241
|
+
},
|
|
242
|
+
instrumentNode(track),
|
|
243
|
+
{
|
|
244
|
+
kind: "toggle",
|
|
245
|
+
label: "mute",
|
|
246
|
+
value: track.muted,
|
|
247
|
+
command: (on) => (on ? "mute" : "unmute"),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
kind: "toggle",
|
|
251
|
+
label: "solo",
|
|
252
|
+
value: track.solo === true,
|
|
253
|
+
command: (on) => (on ? "solo" : "unsolo"),
|
|
254
|
+
},
|
|
255
|
+
volumeNode(track),
|
|
256
|
+
panNode(track),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function instrumentNode(track: Track): MenuNode {
|
|
261
|
+
const options: string[] = [...AVAILABLE_INSTRUMENTS];
|
|
262
|
+
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
263
|
+
return {
|
|
264
|
+
kind: "choice",
|
|
265
|
+
label: "instrument",
|
|
266
|
+
value: track.instrument,
|
|
267
|
+
options,
|
|
268
|
+
command: (option) => `instrument ${option}`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function volumeNode(track: Track): MenuNode {
|
|
273
|
+
return {
|
|
274
|
+
kind: "number",
|
|
275
|
+
label: "volume",
|
|
276
|
+
value: track.volume,
|
|
277
|
+
min: 0,
|
|
278
|
+
max: SCORE_LIMITS.maxVolume,
|
|
279
|
+
step: LANE_STEP.volume,
|
|
280
|
+
format: num,
|
|
281
|
+
command: (value) => `volume ${num(value)}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function panNode(track: Track): MenuNode {
|
|
286
|
+
return {
|
|
287
|
+
kind: "number",
|
|
288
|
+
label: "pan",
|
|
289
|
+
value: track.pan,
|
|
290
|
+
min: -1,
|
|
291
|
+
max: 1,
|
|
292
|
+
step: LANE_STEP.pan,
|
|
293
|
+
format: num,
|
|
294
|
+
command: (value) => `pan ${num(value)}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parameterNodes(context: MenuContext): MenuNode[] {
|
|
299
|
+
const track = focused(context);
|
|
300
|
+
if (!track) return [];
|
|
301
|
+
const nodes: MenuNode[] = [instrumentNode(track)];
|
|
302
|
+
if (track.sampler) {
|
|
303
|
+
nodes.push({
|
|
304
|
+
kind: "info",
|
|
305
|
+
label: "sampler mode",
|
|
306
|
+
value: track.sampler.mode,
|
|
307
|
+
});
|
|
308
|
+
for (const [voice, ref] of Object.entries(track.sampler.voices).sort()) {
|
|
309
|
+
const extras = [
|
|
310
|
+
ref.root !== undefined ? `root ${ref.root}` : "",
|
|
311
|
+
ref.gain !== undefined ? `gain ${num(ref.gain)}` : "",
|
|
312
|
+
ref.speed !== undefined ? `speed ${num(ref.speed)}` : "",
|
|
313
|
+
].filter(Boolean);
|
|
314
|
+
nodes.push({
|
|
315
|
+
kind: "info",
|
|
316
|
+
label: voice,
|
|
317
|
+
value: [ref.src.split("/").at(-1), ...extras].join(" · "),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
nodes.push({
|
|
321
|
+
kind: "info",
|
|
322
|
+
label: "add a voice",
|
|
323
|
+
value: "/sample <path> [as <voice>]",
|
|
324
|
+
});
|
|
325
|
+
} else {
|
|
326
|
+
nodes.push({
|
|
327
|
+
kind: "info",
|
|
328
|
+
label: "voice",
|
|
329
|
+
value:
|
|
330
|
+
"the synth has no knobs beyond the instrument; shape it with Effects",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return nodes;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function effectNodes(context: MenuContext): MenuNode[] {
|
|
337
|
+
const track = focused(context);
|
|
338
|
+
if (!track) return [];
|
|
339
|
+
const filter = track.filter;
|
|
340
|
+
const delay = track.delay;
|
|
341
|
+
const reverb = track.reverb;
|
|
342
|
+
return [
|
|
343
|
+
{
|
|
344
|
+
kind: "menu",
|
|
345
|
+
id: "filter",
|
|
346
|
+
label: "Filter",
|
|
347
|
+
detail: filter
|
|
348
|
+
? `${Math.round(filter.cutoff)} Hz · res ${num(filter.resonance)}`
|
|
349
|
+
: "off",
|
|
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
|
+
],
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
kind: "menu",
|
|
387
|
+
id: "delay",
|
|
388
|
+
label: "Delay",
|
|
389
|
+
detail: delay
|
|
390
|
+
? `${num(delay.beats)} beats · fb ${num(delay.feedback)} · mix ${num(delay.mix)}`
|
|
391
|
+
: "off",
|
|
392
|
+
build: () => {
|
|
393
|
+
const beats = delay?.beats ?? 0.5;
|
|
394
|
+
const feedback = delay?.feedback ?? 0.3;
|
|
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
|
+
];
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
kind: "menu",
|
|
446
|
+
id: "reverb",
|
|
447
|
+
label: "Reverb",
|
|
448
|
+
detail: reverb
|
|
449
|
+
? `mix ${num(reverb.mix)} · size ${num(reverb.size)}`
|
|
450
|
+
: "off",
|
|
451
|
+
build: () => [
|
|
452
|
+
{
|
|
453
|
+
kind: "toggle",
|
|
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
|
+
],
|
|
488
|
+
},
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function automationNodes(context: MenuContext): MenuNode[] {
|
|
493
|
+
const track = focused(context);
|
|
494
|
+
if (!track) return [];
|
|
495
|
+
return (Object.keys(AUTOMATION_LANES) as AutomationParameter[]).map(
|
|
496
|
+
(lane) => {
|
|
497
|
+
const count = track[AUTOMATION_LANES[lane].field]?.length ?? 0;
|
|
498
|
+
return {
|
|
499
|
+
kind: "menu",
|
|
500
|
+
id: `lane:${lane}`,
|
|
501
|
+
label: LANE_LABEL[lane],
|
|
502
|
+
detail: `${count} point${count === 1 ? "" : "s"}`,
|
|
503
|
+
build: (inner) => laneNodes(inner, lane),
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** `4:0.5 8:1` → `automate <lane> points 4:0.5 8:1`, when every pair parses. */
|
|
510
|
+
function pointsCommand(
|
|
511
|
+
lane: AutomationParameter,
|
|
512
|
+
text: string,
|
|
513
|
+
exactly?: number,
|
|
514
|
+
): string | undefined {
|
|
515
|
+
const pairs = text
|
|
516
|
+
.trim()
|
|
517
|
+
.split(/[\s,]+/)
|
|
518
|
+
.filter(Boolean);
|
|
519
|
+
if (pairs.length === 0 || (exactly !== undefined && pairs.length !== exactly))
|
|
520
|
+
return undefined;
|
|
521
|
+
const { min, max } = AUTOMATION_LANES[lane];
|
|
522
|
+
for (const pair of pairs) {
|
|
523
|
+
const match = pair.match(/^(\d+(?:\.\d+)?):(-?\d+(?:\.\d+)?)$/);
|
|
524
|
+
if (!match) return undefined;
|
|
525
|
+
const value = Number(match[2]);
|
|
526
|
+
if (value < min || value > max) return undefined;
|
|
527
|
+
}
|
|
528
|
+
return `automate ${lane} points ${pairs.join(" ")}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function laneNodes(
|
|
532
|
+
context: MenuContext,
|
|
533
|
+
lane: AutomationParameter,
|
|
534
|
+
): MenuNode[] {
|
|
535
|
+
const track = focused(context);
|
|
536
|
+
if (!track) return [];
|
|
537
|
+
const { min, max, field } = AUTOMATION_LANES[lane];
|
|
538
|
+
const range = `${laneFormat(lane, min)}…${laneFormat(lane, max)}`;
|
|
539
|
+
const points = track[field] ?? [];
|
|
540
|
+
const nodes: MenuNode[] = [
|
|
541
|
+
{
|
|
542
|
+
kind: "entry",
|
|
543
|
+
label: "add points",
|
|
544
|
+
value: range,
|
|
545
|
+
placeholder: "beat:value [beat:value …]",
|
|
546
|
+
command: (text) => pointsCommand(lane, text),
|
|
547
|
+
example: `automate ${lane} points 0:${laneFormat(lane, min)} 4:${laneFormat(lane, max)}`,
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
kind: "entry",
|
|
551
|
+
label: "ramp",
|
|
552
|
+
value: "two points",
|
|
553
|
+
placeholder: "from-beat:value to-beat:value",
|
|
554
|
+
command: (text) => pointsCommand(lane, text, 2),
|
|
555
|
+
example: `automate ${lane} points 0:${laneFormat(lane, min)} ${context.score.beatsPerBar * context.score.bars}:${laneFormat(lane, max)}`,
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
for (const point of points)
|
|
559
|
+
nodes.push({
|
|
560
|
+
kind: "point",
|
|
561
|
+
label: `beat ${num(point.tick / context.score.ticksPerBeat)}`,
|
|
562
|
+
lane,
|
|
563
|
+
beat: point.tick / context.score.ticksPerBeat,
|
|
564
|
+
value: point.value,
|
|
565
|
+
});
|
|
566
|
+
if (points.length > 0)
|
|
567
|
+
nodes.push({
|
|
568
|
+
kind: "action",
|
|
569
|
+
label: "clear lane",
|
|
570
|
+
command: `clear ${lane} automation`,
|
|
571
|
+
});
|
|
572
|
+
return nodes;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function mixNodes(context: MenuContext): MenuNode[] {
|
|
576
|
+
return context.score.tracks.map((track): MenuNode => {
|
|
577
|
+
const detail = `vol ${num(track.volume)} · pan ${num(track.pan)}${track.muted ? " · M" : ""}${track.solo ? " · S" : ""}`;
|
|
578
|
+
// Commands act on the focused track, so another track is focused first.
|
|
579
|
+
return track.id === context.trackId
|
|
580
|
+
? {
|
|
581
|
+
kind: "menu",
|
|
582
|
+
id: `mix:${track.id}`,
|
|
583
|
+
label: `${track.name} ●`,
|
|
584
|
+
detail,
|
|
585
|
+
build: trackNodes,
|
|
586
|
+
}
|
|
587
|
+
: {
|
|
588
|
+
kind: "action",
|
|
589
|
+
label: `${track.name} ${detail}`,
|
|
590
|
+
command: `/track ${track.id}`,
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function transportNodes(context: MenuContext): MenuNode[] {
|
|
596
|
+
const score = context.score;
|
|
597
|
+
return [
|
|
598
|
+
{
|
|
599
|
+
kind: "action",
|
|
600
|
+
label: context.playing ? "pause" : "play",
|
|
601
|
+
command: context.playing ? "pause" : "play",
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
kind: "number",
|
|
605
|
+
label: "tempo BPM",
|
|
606
|
+
value: score.tempoBpm,
|
|
607
|
+
min: SCORE_LIMITS.minTempoBpm,
|
|
608
|
+
max: SCORE_LIMITS.maxTempoBpm,
|
|
609
|
+
step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
|
|
610
|
+
format: num,
|
|
611
|
+
command: (value) => `tempo ${num(value)}`,
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
kind: "number",
|
|
615
|
+
label: "beats per bar",
|
|
616
|
+
value: score.beatsPerBar,
|
|
617
|
+
min: 1,
|
|
618
|
+
max: SCORE_LIMITS.maxBeatsPerBar,
|
|
619
|
+
step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
|
|
620
|
+
format: (value) => `${value}/4`,
|
|
621
|
+
command: (value) => `meter ${Math.round(value)}`,
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
kind: "number",
|
|
625
|
+
label: "loop bars",
|
|
626
|
+
value: score.bars,
|
|
627
|
+
min: 1,
|
|
628
|
+
max: SCORE_LIMITS.maxBars,
|
|
629
|
+
step: linear(1, 1, SCORE_LIMITS.maxBars),
|
|
630
|
+
format: num,
|
|
631
|
+
command: (value) => `bars ${Math.round(value)}`,
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
kind: "choice",
|
|
635
|
+
label: "grid",
|
|
636
|
+
value: context.grid,
|
|
637
|
+
options: context.grids,
|
|
638
|
+
command: (option) => `/grid ${option}`,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
kind: "toggle",
|
|
642
|
+
label: "click",
|
|
643
|
+
value: context.clickOn,
|
|
644
|
+
command: (on) => `/click ${on ? "on" : "off"}`,
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
kind: "choice",
|
|
648
|
+
label: "count-in bars",
|
|
649
|
+
value: String(context.countInBars),
|
|
650
|
+
options: ["0", "1", "2"],
|
|
651
|
+
command: (option) => `/count-in ${option}`,
|
|
652
|
+
},
|
|
653
|
+
];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── controller ────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
type Frame = {
|
|
659
|
+
title: string;
|
|
660
|
+
build: (context: MenuContext) => MenuNode[];
|
|
661
|
+
index: number;
|
|
662
|
+
query: string;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
|
|
666
|
+
const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
|
|
667
|
+
const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
668
|
+
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
669
|
+
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
670
|
+
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
671
|
+
const KEY_DELETE = new Set(["\u001b[3~", "x"]);
|
|
672
|
+
const LABEL_WIDTH = 16;
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* A submenu frame whose rows are rebuilt from the parent's fresh build, so
|
|
676
|
+
* values (and the commands closed over them) always follow the score.
|
|
677
|
+
*/
|
|
678
|
+
function childFrame(
|
|
679
|
+
parent: Frame,
|
|
680
|
+
node: Extract<MenuNode, { kind: "menu" }>,
|
|
681
|
+
): Frame {
|
|
682
|
+
return {
|
|
683
|
+
title: node.label,
|
|
684
|
+
index: 0,
|
|
685
|
+
query: "",
|
|
686
|
+
build: (context) => {
|
|
687
|
+
const fresh = parent
|
|
688
|
+
.build(context)
|
|
689
|
+
.find(
|
|
690
|
+
(candidate): candidate is Extract<MenuNode, { kind: "menu" }> =>
|
|
691
|
+
candidate.kind === "menu" && candidate.id === node.id,
|
|
692
|
+
);
|
|
693
|
+
return (fresh ?? node).build(context);
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export class EditMenu {
|
|
699
|
+
private stack: Frame[] = [];
|
|
700
|
+
private filtering = false;
|
|
701
|
+
/** Typed value for the selected row (`entry` holds the row's label). */
|
|
702
|
+
private entry: { label: string; buffer: string } | undefined;
|
|
703
|
+
|
|
704
|
+
get open(): boolean {
|
|
705
|
+
return this.stack.length > 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Open at the root, or at a section id (`effects`, `automation`). */
|
|
709
|
+
show(context: MenuContext, section?: string): void {
|
|
710
|
+
this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
|
|
711
|
+
this.filtering = false;
|
|
712
|
+
this.entry = undefined;
|
|
713
|
+
if (section) {
|
|
714
|
+
const nodes = rootNodes(context);
|
|
715
|
+
const index = nodes.findIndex(
|
|
716
|
+
(node) => node.kind === "menu" && node.id === section.toLowerCase(),
|
|
717
|
+
);
|
|
718
|
+
const node = nodes[index];
|
|
719
|
+
if (node?.kind === "menu") {
|
|
720
|
+
this.stack[0]!.index = index;
|
|
721
|
+
this.stack.push(childFrame(this.stack[0]!, node));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
close(): void {
|
|
727
|
+
this.stack = [];
|
|
728
|
+
this.filtering = false;
|
|
729
|
+
this.entry = undefined;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The visible rows of the current level (filtered). */
|
|
733
|
+
nodes(context: MenuContext): MenuNode[] {
|
|
734
|
+
const frame = this.stack.at(-1);
|
|
735
|
+
if (!frame) return [];
|
|
736
|
+
const all = frame.build(context);
|
|
737
|
+
const needle = frame.query.toLowerCase();
|
|
738
|
+
return needle
|
|
739
|
+
? all.filter((node) => nodeText(node).toLowerCase().includes(needle))
|
|
740
|
+
: all;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private selected(context: MenuContext): MenuNode | undefined {
|
|
744
|
+
const frame = this.stack.at(-1);
|
|
745
|
+
const nodes = this.nodes(context);
|
|
746
|
+
if (!frame || nodes.length === 0) return undefined;
|
|
747
|
+
frame.index = clamp(frame.index, 0, nodes.length - 1);
|
|
748
|
+
return nodes[frame.index];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
key(value: string, context: MenuContext): MenuResult {
|
|
752
|
+
const frame = this.stack.at(-1);
|
|
753
|
+
if (!frame) return { type: "pass" };
|
|
754
|
+
if (value === "\u0003" || value === "\u000c") return { type: "pass" };
|
|
755
|
+
if (this.entry) return this.entryKey(value, context);
|
|
756
|
+
if (this.filtering) {
|
|
757
|
+
if (value === "\u001b") {
|
|
758
|
+
frame.query = "";
|
|
759
|
+
this.filtering = false;
|
|
760
|
+
return { type: "handled" };
|
|
761
|
+
}
|
|
762
|
+
if (KEY_BACKSPACE.has(value)) {
|
|
763
|
+
frame.query = frame.query.slice(0, -1);
|
|
764
|
+
if (!frame.query) this.filtering = false;
|
|
765
|
+
frame.index = 0;
|
|
766
|
+
return { type: "handled" };
|
|
767
|
+
}
|
|
768
|
+
if (
|
|
769
|
+
KEY_ENTER.has(value) ||
|
|
770
|
+
value === "\u001b[B" ||
|
|
771
|
+
value === "\u001b[A"
|
|
772
|
+
) {
|
|
773
|
+
this.filtering = false;
|
|
774
|
+
return this.key(value, context);
|
|
775
|
+
}
|
|
776
|
+
if (value.length === 1 && value >= " ") {
|
|
777
|
+
frame.query = (frame.query + value).slice(0, 40);
|
|
778
|
+
frame.index = 0;
|
|
779
|
+
return { type: "handled" };
|
|
780
|
+
}
|
|
781
|
+
return { type: "handled" };
|
|
782
|
+
}
|
|
783
|
+
const nodes = this.nodes(context);
|
|
784
|
+
const node = this.selected(context);
|
|
785
|
+
if (value === "\u001b") {
|
|
786
|
+
if (frame.query) {
|
|
787
|
+
frame.query = "";
|
|
788
|
+
return { type: "handled" };
|
|
789
|
+
}
|
|
790
|
+
this.stack.pop();
|
|
791
|
+
return this.stack.length ? { type: "handled" } : { type: "close" };
|
|
792
|
+
}
|
|
793
|
+
if (value === "/") {
|
|
794
|
+
this.filtering = true;
|
|
795
|
+
frame.query = "";
|
|
796
|
+
return { type: "handled" };
|
|
797
|
+
}
|
|
798
|
+
if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
|
|
799
|
+
const step = KEY_UP.has(value) ? -1 : 1;
|
|
800
|
+
if (nodes.length)
|
|
801
|
+
frame.index = (frame.index + step + nodes.length) % nodes.length;
|
|
802
|
+
return { type: "handled" };
|
|
803
|
+
}
|
|
804
|
+
if (value === "\u001b[5~" || value === "\u001b[H") {
|
|
805
|
+
frame.index = 0;
|
|
806
|
+
return { type: "handled" };
|
|
807
|
+
}
|
|
808
|
+
if (value === "\u001b[6~" || value === "\u001b[F") {
|
|
809
|
+
frame.index = Math.max(0, nodes.length - 1);
|
|
810
|
+
return { type: "handled" };
|
|
811
|
+
}
|
|
812
|
+
if (!node) return { type: "handled" };
|
|
813
|
+
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
814
|
+
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
815
|
+
return this.nudge(node, direction);
|
|
816
|
+
}
|
|
817
|
+
if (KEY_DELETE.has(value) && node.kind === "point")
|
|
818
|
+
return {
|
|
819
|
+
type: "run",
|
|
820
|
+
command: `automate ${node.lane} remove ${num(node.beat)}`,
|
|
821
|
+
};
|
|
822
|
+
if (
|
|
823
|
+
/^[0-9.]$/.test(value) &&
|
|
824
|
+
(node.kind === "number" || node.kind === "point")
|
|
825
|
+
) {
|
|
826
|
+
this.entry = { label: node.label, buffer: value };
|
|
827
|
+
return { type: "handled" };
|
|
828
|
+
}
|
|
829
|
+
if (KEY_ENTER.has(value) || value === " ") {
|
|
830
|
+
if (node.kind === "menu") {
|
|
831
|
+
// Going back lands on the opened row with the filter cleared.
|
|
832
|
+
if (frame.query) {
|
|
833
|
+
frame.query = "";
|
|
834
|
+
frame.index = Math.max(
|
|
835
|
+
0,
|
|
836
|
+
this.nodes(context).findIndex(
|
|
837
|
+
(candidate) =>
|
|
838
|
+
candidate.kind === "menu" && candidate.id === node.id,
|
|
839
|
+
),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
this.stack.push(childFrame(frame, node));
|
|
843
|
+
return { type: "handled" };
|
|
844
|
+
}
|
|
845
|
+
if (node.kind === "toggle")
|
|
846
|
+
return { type: "run", command: node.command(!node.value) };
|
|
847
|
+
if (node.kind === "action") return { type: "run", command: node.command };
|
|
848
|
+
if (node.kind === "choice") {
|
|
849
|
+
const current = node.options.indexOf(node.value);
|
|
850
|
+
this.stack.push({
|
|
851
|
+
title: node.label,
|
|
852
|
+
index: Math.max(0, current),
|
|
853
|
+
query: "",
|
|
854
|
+
build: (fresh) => {
|
|
855
|
+
const parent = this.stack.at(-2);
|
|
856
|
+
const live =
|
|
857
|
+
parent
|
|
858
|
+
?.build(fresh)
|
|
859
|
+
.find(
|
|
860
|
+
(candidate): candidate is typeof node =>
|
|
861
|
+
candidate.kind === "choice" &&
|
|
862
|
+
candidate.label === node.label,
|
|
863
|
+
) ?? node;
|
|
864
|
+
return live.options.map((option) => ({
|
|
865
|
+
kind: "action",
|
|
866
|
+
label: option === live.value ? `${option} ✓` : option,
|
|
867
|
+
command: live.command(option),
|
|
868
|
+
}));
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
return { type: "handled" };
|
|
872
|
+
}
|
|
873
|
+
if (
|
|
874
|
+
node.kind === "entry" ||
|
|
875
|
+
node.kind === "number" ||
|
|
876
|
+
node.kind === "point"
|
|
877
|
+
) {
|
|
878
|
+
this.entry = { label: node.label, buffer: "" };
|
|
879
|
+
return { type: "handled" };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return { type: "handled" };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
|
|
886
|
+
if (node.kind === "menu") {
|
|
887
|
+
if (direction > 0) {
|
|
888
|
+
this.stack.push(childFrame(this.stack.at(-1)!, node));
|
|
889
|
+
return { type: "handled" };
|
|
890
|
+
}
|
|
891
|
+
if (this.stack.length > 1) this.stack.pop();
|
|
892
|
+
return { type: "handled" };
|
|
893
|
+
}
|
|
894
|
+
if (node.kind === "number") {
|
|
895
|
+
const next =
|
|
896
|
+
node.value === undefined
|
|
897
|
+
? (node.start ?? node.min)
|
|
898
|
+
: clamp(node.step(node.value, direction), node.min, node.max);
|
|
899
|
+
if (node.value !== undefined && Math.abs(next - node.value) < 1e-9)
|
|
900
|
+
return { type: "handled" };
|
|
901
|
+
return { type: "run", command: node.command(next) };
|
|
902
|
+
}
|
|
903
|
+
if (node.kind === "point") {
|
|
904
|
+
const { min, max } = AUTOMATION_LANES[node.lane];
|
|
905
|
+
const next = clamp(LANE_STEP[node.lane](node.value, direction), min, max);
|
|
906
|
+
if (Math.abs(next - node.value) < 1e-9) return { type: "handled" };
|
|
907
|
+
return {
|
|
908
|
+
type: "run",
|
|
909
|
+
command: `automate ${node.lane} points ${num(node.beat)}:${laneFormat(node.lane, next)}`,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (node.kind === "toggle") {
|
|
913
|
+
const want = direction > 0;
|
|
914
|
+
return want === node.value
|
|
915
|
+
? { type: "handled" }
|
|
916
|
+
: { type: "run", command: node.command(want) };
|
|
917
|
+
}
|
|
918
|
+
if (node.kind === "choice") {
|
|
919
|
+
const at = node.options.indexOf(node.value);
|
|
920
|
+
const next =
|
|
921
|
+
node.options[clamp(at + direction, 0, node.options.length - 1)];
|
|
922
|
+
return next === undefined || next === node.value
|
|
923
|
+
? { type: "handled" }
|
|
924
|
+
: { type: "run", command: node.command(next) };
|
|
925
|
+
}
|
|
926
|
+
if (direction < 0 && this.stack.length > 1) this.stack.pop();
|
|
927
|
+
return { type: "handled" };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private entryKey(value: string, context: MenuContext): MenuResult {
|
|
931
|
+
const entry = this.entry!;
|
|
932
|
+
if (value === "\u001b") {
|
|
933
|
+
this.entry = undefined;
|
|
934
|
+
return { type: "handled" };
|
|
935
|
+
}
|
|
936
|
+
if (KEY_BACKSPACE.has(value)) {
|
|
937
|
+
entry.buffer = entry.buffer.slice(0, -1);
|
|
938
|
+
return { type: "handled" };
|
|
939
|
+
}
|
|
940
|
+
if (KEY_ENTER.has(value)) {
|
|
941
|
+
const node = this.selected(context);
|
|
942
|
+
this.entry = undefined;
|
|
943
|
+
const command = node ? entryCommand(node, entry.buffer) : undefined;
|
|
944
|
+
if (!command) return { type: "handled" };
|
|
945
|
+
// A committed value clears the filter so its result (a new automation
|
|
946
|
+
// point, a renamed row) is in view, keeping the cursor on the row.
|
|
947
|
+
const frame = this.stack.at(-1)!;
|
|
948
|
+
if (frame.query && node) {
|
|
949
|
+
frame.query = "";
|
|
950
|
+
const index = this.nodes(context).findIndex(
|
|
951
|
+
(candidate) => candidate.label === node.label,
|
|
952
|
+
);
|
|
953
|
+
frame.index = Math.max(0, index);
|
|
954
|
+
}
|
|
955
|
+
return { type: "run", command };
|
|
956
|
+
}
|
|
957
|
+
if (value.length >= 1 && !value.startsWith("\u001b") && value >= " ") {
|
|
958
|
+
entry.buffer = (entry.buffer + value).slice(0, 200);
|
|
959
|
+
}
|
|
960
|
+
return { type: "handled" };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** The picker the TUI draws for the current level. */
|
|
964
|
+
view(context: MenuContext): MenuView {
|
|
965
|
+
const frame = this.stack.at(-1);
|
|
966
|
+
const nodes = this.nodes(context);
|
|
967
|
+
const index = frame
|
|
968
|
+
? clamp(frame.index, 0, Math.max(0, nodes.length - 1))
|
|
969
|
+
: 0;
|
|
970
|
+
const crumbs = this.stack.map((level) => level.title).join(" › ");
|
|
971
|
+
const selected = nodes[index];
|
|
972
|
+
let title = crumbs;
|
|
973
|
+
if (frame?.query || this.filtering) title += ` · /${frame?.query ?? ""}`;
|
|
974
|
+
if (this.entry)
|
|
975
|
+
title += ` · ${this.entry.label}: ${this.entry.buffer || placeholderFor(selected)}▏`;
|
|
976
|
+
const items = nodes.map((node, at) => ({
|
|
977
|
+
label: `${node.label.padEnd(LABEL_WIDTH)} ${valueText(node)}`.trimEnd(),
|
|
978
|
+
detail: commandText(node),
|
|
979
|
+
value: String(at),
|
|
980
|
+
}));
|
|
981
|
+
const hint = this.entry
|
|
982
|
+
? " type a value · enter apply · esc cancel "
|
|
983
|
+
: this.filtering
|
|
984
|
+
? " type to filter · enter choose · esc clear "
|
|
985
|
+
: selected?.kind === "point"
|
|
986
|
+
? " ←→ nudge · digits set · x delete · esc back "
|
|
987
|
+
: selected?.kind === "number" ||
|
|
988
|
+
selected?.kind === "toggle" ||
|
|
989
|
+
selected?.kind === "choice"
|
|
990
|
+
? " ←→/+- nudge · digits set · enter edit · / filter · esc back "
|
|
991
|
+
: " ↑↓ move · enter open · / filter · esc back ";
|
|
992
|
+
return { title, items, index, hint };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function placeholderFor(node: MenuNode | undefined): string {
|
|
997
|
+
if (!node) return "";
|
|
998
|
+
if (node.kind === "entry") return node.placeholder;
|
|
999
|
+
if (node.kind === "number")
|
|
1000
|
+
return `${node.format(node.min)}…${node.format(node.max)}`;
|
|
1001
|
+
if (node.kind === "point") return "value";
|
|
1002
|
+
return "";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function entryCommand(node: MenuNode, text: string): string | undefined {
|
|
1006
|
+
const trimmed = text.trim();
|
|
1007
|
+
if (node.kind === "entry") return node.command(trimmed);
|
|
1008
|
+
if (node.kind === "number") {
|
|
1009
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) return undefined;
|
|
1010
|
+
const value = Number(trimmed);
|
|
1011
|
+
if (value < node.min || value > node.max) return undefined;
|
|
1012
|
+
return node.command(value);
|
|
1013
|
+
}
|
|
1014
|
+
if (node.kind === "point") {
|
|
1015
|
+
// `0.8` sets the value; `6:0.8` moves to beat 6.
|
|
1016
|
+
const { min, max } = AUTOMATION_LANES[node.lane];
|
|
1017
|
+
const match = trimmed.match(/^(?:(\d+(?:\.\d+)?):)?(-?\d+(?:\.\d+)?)$/);
|
|
1018
|
+
if (!match) return undefined;
|
|
1019
|
+
const value = Number(match[2]);
|
|
1020
|
+
if (value < min || value > max) return undefined;
|
|
1021
|
+
if (match[1] !== undefined && Number(match[1]) !== node.beat)
|
|
1022
|
+
return `automate ${node.lane} points ${match[1]}:${match[2]}`;
|
|
1023
|
+
return `automate ${node.lane} points ${num(node.beat)}:${match[2]}`;
|
|
1024
|
+
}
|
|
1025
|
+
return undefined;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function valueText(node: MenuNode): string {
|
|
1029
|
+
switch (node.kind) {
|
|
1030
|
+
case "menu":
|
|
1031
|
+
return node.detail;
|
|
1032
|
+
case "number":
|
|
1033
|
+
return node.value === undefined
|
|
1034
|
+
? (node.off ?? "—")
|
|
1035
|
+
: node.format(node.value);
|
|
1036
|
+
case "toggle":
|
|
1037
|
+
return node.value ? "on" : "off";
|
|
1038
|
+
case "choice":
|
|
1039
|
+
return node.value;
|
|
1040
|
+
case "entry":
|
|
1041
|
+
return node.value;
|
|
1042
|
+
case "point":
|
|
1043
|
+
return laneFormat(node.lane, node.value);
|
|
1044
|
+
case "info":
|
|
1045
|
+
return node.value;
|
|
1046
|
+
case "action":
|
|
1047
|
+
return "";
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** The command a row stands for, shown so the menu teaches commands. */
|
|
1052
|
+
function commandText(node: MenuNode): string | undefined {
|
|
1053
|
+
switch (node.kind) {
|
|
1054
|
+
case "number":
|
|
1055
|
+
return node.command(node.value ?? node.start ?? node.min);
|
|
1056
|
+
case "toggle":
|
|
1057
|
+
return node.command(!node.value);
|
|
1058
|
+
case "choice":
|
|
1059
|
+
return node.command(node.value);
|
|
1060
|
+
case "entry":
|
|
1061
|
+
return node.example;
|
|
1062
|
+
case "action":
|
|
1063
|
+
return node.command;
|
|
1064
|
+
case "point":
|
|
1065
|
+
return `automate ${node.lane} points ${num(node.beat)}:${laneFormat(node.lane, node.value)}`;
|
|
1066
|
+
default:
|
|
1067
|
+
return undefined;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function nodeText(node: MenuNode): string {
|
|
1072
|
+
return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Root sections, for `/menu <section>`. */
|
|
1076
|
+
export const MENU_SECTIONS = [
|
|
1077
|
+
"track",
|
|
1078
|
+
"parameters",
|
|
1079
|
+
"effects",
|
|
1080
|
+
"automation",
|
|
1081
|
+
"mix",
|
|
1082
|
+
"transport",
|
|
1083
|
+
] as const;
|