@hraness/dawg 0.2.0 → 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.
Files changed (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -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;