@hraness/dawg 0.7.0 → 0.8.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A pane's handle on the machine's one live engine (design §12.3). It looks
3
+ * like the local `LiveEngine` to play mode, but notes go to dawgd as note
4
+ * events (`remoteNote`) and the click as a structured request
5
+ * (`remoteClick`): no audio crosses the socket. dawgd renders each voice
6
+ * from the score it holds and mixes every pane into one stream and one
7
+ * click.
8
+ *
9
+ * Times cross the seam on the authority's clock: local monotonic ms plus
10
+ * `performance.timeOrigin` plus the port's clock offset (0 on one machine).
11
+ */
12
+ import type { ClickBus } from "../audio/engine.ts";
13
+ import type { LiveNotePcm } from "../audio/live.ts";
14
+ import type { LiveClick, LiveMessage, LiveStatus } from "./protocol.ts";
15
+
16
+ /** What the shared engine needs from the session connection. */
17
+ export interface LiveLink {
18
+ liveMonitor(on: boolean): Promise<LiveStatus>;
19
+ live(message: Exclude<LiveMessage, { action: "monitor" }>): void;
20
+ /** Authority clock minus this machine's, in ms. */
21
+ readonly clockOffsetMs: number;
22
+ }
23
+
24
+ /** A played note as play mode hands it to a shared engine. */
25
+ export type RemoteNote = Readonly<{
26
+ trackId: string;
27
+ pitch: number;
28
+ /** 0..1. */
29
+ velocity: number;
30
+ seconds: number;
31
+ /** Transport beat of the press (unwrapped). */
32
+ beat: number;
33
+ /** Monotonic ms of the press. */
34
+ atMs: number;
35
+ }>;
36
+
37
+ /** A structured click request (a shared engine cannot take closures). */
38
+ export type RemoteClick = Readonly<{
39
+ on: boolean;
40
+ volume: number;
41
+ countIn?: Readonly<{
42
+ /** Monotonic ms the count-in starts. */
43
+ startMs: number;
44
+ startBeat: number;
45
+ beats: number;
46
+ barBeats: number;
47
+ clickBeats: number;
48
+ bpm: number;
49
+ }>;
50
+ }>;
51
+
52
+ export class SharedLiveEngine {
53
+ public sampleRate = 48_000;
54
+ public canMonitor = true;
55
+ public leadMs = 15;
56
+ public playLeadMs: number | undefined;
57
+ public audioNote: string | undefined = "shared engine";
58
+ /** Notes sent (tests and the status line read it). */
59
+ public sent = 0;
60
+
61
+ public constructor(private readonly link: LiveLink) {}
62
+
63
+ public async monitor(on: boolean): Promise<void> {
64
+ try {
65
+ const status = await this.link.liveMonitor(on);
66
+ this.sampleRate = status.sampleRate;
67
+ this.canMonitor = status.canMonitor;
68
+ this.leadMs = status.leadMs;
69
+ this.playLeadMs = status.leadMs;
70
+ this.audioNote = status.note ?? "shared engine";
71
+ } catch {
72
+ // dawgd is reconnecting; the link resends monitor once it is back.
73
+ }
74
+ }
75
+
76
+ /** The lead is dawgd's: a pane never retimes the shared engine. */
77
+ public setLeadMs(_ms: number | undefined): void {}
78
+
79
+ /** Local PCM never reaches a shared engine; play mode uses `remoteNote`. */
80
+ public noteOn(_id: number, _note: LiveNotePcm): number {
81
+ return performance.now() + this.leadMs;
82
+ }
83
+
84
+ public remoteNote(id: number, note: RemoteNote): number {
85
+ this.sent += 1;
86
+ this.link.live({
87
+ v: 1,
88
+ type: "live",
89
+ action: "on",
90
+ voice: id,
91
+ trackId: note.trackId,
92
+ pitch: note.pitch,
93
+ velocity: Math.max(0, Math.min(1, note.velocity)),
94
+ seconds: Math.max(0, Math.min(600, note.seconds)),
95
+ beat: note.beat,
96
+ atMs: this.authority(note.atMs),
97
+ });
98
+ return performance.now() + this.leadMs;
99
+ }
100
+
101
+ public noteOff(id: number): void {
102
+ this.link.live({
103
+ v: 1,
104
+ type: "live",
105
+ action: "off",
106
+ voice: id,
107
+ atMs: this.authority(performance.now()),
108
+ });
109
+ }
110
+
111
+ /** A pane cannot hand dawgd a click closure; see `remoteClick`. */
112
+ public setClick(click: ClickBus | undefined): void {
113
+ if (!click) this.remoteClick({ on: false, volume: 0 });
114
+ }
115
+
116
+ public remoteClick(click: RemoteClick): void {
117
+ const message: LiveClick = {
118
+ on: click.on,
119
+ volume: Math.max(0, Math.min(1, click.volume)),
120
+ };
121
+ if (click.countIn) {
122
+ const { startMs, ...rest } = click.countIn;
123
+ message.countIn = { ...rest, startAtMs: this.authority(startMs) };
124
+ }
125
+ this.link.live({ v: 1, type: "live", action: "click", ...message });
126
+ }
127
+
128
+ private authority(monotonicMs: number): number {
129
+ return performance.timeOrigin + monotonicMs + this.link.clockOffsetMs;
130
+ }
131
+ }
@@ -39,12 +39,36 @@ export class SessionValidationError extends Error {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Who made an event. Stamped by the authority from the connection's hello,
44
+ * never trusted from the intent: `actorId` is the person (absent for v1
45
+ * clients and the file fallback), `clientId` the window, `seq` the
46
+ * per-(actor, client) intent number used for retry dedupe.
47
+ */
48
+ export type EventActor = {
49
+ actorId?: string;
50
+ clientId: string;
51
+ seq?: number;
52
+ };
53
+
42
54
  export type SessionEvent = {
43
55
  id: string;
44
56
  revision: number;
57
+ /**
58
+ * Opaque to the store: any bounded string is kept and passed through, so a
59
+ * newer peer's event kinds survive an older reader.
60
+ */
45
61
  kind: string;
46
62
  payload: unknown;
47
63
  at: string;
64
+ actor?: EventActor;
65
+ /**
66
+ * The score operations this event applied: sent by an ops intent, or
67
+ * derived by the authority from a composition intent. Folding `ops` from
68
+ * the previous composition reproduces this one. Events without `ops` are
69
+ * snapshots (too large to diff, or not expressible as operations).
70
+ */
71
+ ops?: unknown[];
48
72
  /**
49
73
  * How to recover the composition this event replaced from the one it
50
74
  * produced (see `delta.ts`). Absent once compacted away: the store drops
@@ -204,7 +228,10 @@ export async function appendSessionEvent<T>(
204
228
  // History is the store's job: a `before` composition in the payload (the
205
229
  // pre-rewind convention) is dropped rather than persisted in full.
206
230
  const payload = stripBefore(event.payload);
207
- const payloadJson = stringifyJson(payload, "session event payload");
231
+ const payloadJson = stringifyJson(
232
+ event.ops === undefined ? payload : [payload, event.ops],
233
+ "session event payload",
234
+ );
208
235
  const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
209
236
  if (payloadBytes > MAX_EVENT_BYTES)
210
237
  throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
@@ -231,6 +258,8 @@ export async function appendSessionEvent<T>(
231
258
  revision,
232
259
  at,
233
260
  };
261
+ if (event.actor !== undefined) appended.actor = { ...event.actor };
262
+ if (event.ops !== undefined) appended.ops = [...event.ops];
234
263
  const rewind = diffRewind(disk.composition, composition);
235
264
  if (Buffer.byteLength(JSON.stringify(rewind), "utf8") <= MAX_REWIND_BYTES)
236
265
  appended.rewind = rewind;
@@ -523,7 +552,9 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
523
552
  )
524
553
  throw new SessionValidationError("session event timestamp is invalid");
525
554
  const payloadJson = stringifyJson(
526
- candidate.payload,
555
+ candidate.ops === undefined
556
+ ? candidate.payload
557
+ : [candidate.payload, candidate.ops],
527
558
  "session event payload",
528
559
  );
529
560
  if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
@@ -548,6 +579,13 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
548
579
  at: candidate.at,
549
580
  };
550
581
  if (rewind !== undefined) parsed.rewind = rewind;
582
+ if (candidate.actor !== undefined)
583
+ parsed.actor = parseEventActor(candidate.actor);
584
+ if (candidate.ops !== undefined) {
585
+ if (!Array.isArray(candidate.ops))
586
+ throw new SessionValidationError("session event ops are invalid");
587
+ parsed.ops = candidate.ops;
588
+ }
551
589
  return parsed;
552
590
  });
553
591
  let meta: SessionMeta;
@@ -601,3 +639,24 @@ function stringifyJson(value: unknown, label: string): string {
601
639
  throw new SessionValidationError(`${label} must be JSON data`);
602
640
  }
603
641
  }
642
+
643
+ function parseEventActor(value: unknown): EventActor {
644
+ if (typeof value !== "object" || value === null || Array.isArray(value))
645
+ throw new SessionValidationError("session event actor is invalid");
646
+ const actor = value as Record<string, unknown>;
647
+ const valid =
648
+ typeof actor.clientId === "string" &&
649
+ actor.clientId.length > 0 &&
650
+ actor.clientId.length <= 64 &&
651
+ (actor.actorId === undefined ||
652
+ (typeof actor.actorId === "string" &&
653
+ /^a_[a-z2-7]{22}$/.test(actor.actorId))) &&
654
+ (actor.seq === undefined ||
655
+ (Number.isSafeInteger(actor.seq) && (actor.seq as number) >= 1));
656
+ if (!valid)
657
+ throw new SessionValidationError("session event actor is invalid");
658
+ const parsed: EventActor = { clientId: actor.clientId as string };
659
+ if (actor.actorId !== undefined) parsed.actorId = actor.actorId as string;
660
+ if (actor.seq !== undefined) parsed.seq = actor.seq as number;
661
+ return parsed;
662
+ }
@@ -7,6 +7,7 @@
7
7
  import type { Section } from "../../core/score.ts";
8
8
  import { formatForm } from "../../core/sections.ts";
9
9
  import { barsLabel } from "../commands/arrange.ts";
10
+ import { rangeLabel } from "../../core/range.ts";
10
11
  import type { MenuContext, MenuNode } from "./menu.ts";
11
12
  import { styleMenuDetail, styleMenuNodes } from "./style-menu.ts";
12
13
 
@@ -21,6 +22,151 @@ export function arrangeDetail(context: MenuContext): string {
21
22
  return parts.join(" · ");
22
23
  }
23
24
 
25
+ /** `loop 5–6 · 16 bars`, or the song length alone. */
26
+ export function rangeDetail(context: MenuContext): string {
27
+ const score = context.score;
28
+ const bars = `${score.bars} bar${score.bars === 1 ? "" : "s"}`;
29
+ return score.loop ? `loop ${rangeLabel(score.loop)} · ${bars}` : bars;
30
+ }
31
+
32
+ /** A bar range typed as `5-6` or `5`: kept as typed, else undefined. */
33
+ function barsText(text: string): string | undefined {
34
+ const value = text.trim().replace(/\s*(?:–|\.\.)\s*/u, "-");
35
+ return /^\d{1,4}(?:-\d{1,4})?$/u.test(value) ? value : undefined;
36
+ }
37
+
38
+ /**
39
+ * Arrange › range (op1-ux §6.4): each row runs the typed range command on
40
+ * the focused track, so the menu, the prompt and the agent share one path.
41
+ */
42
+ export function rangeNodes(context: MenuContext): MenuNode[] {
43
+ const score = context.score;
44
+ const track = context.trackId;
45
+ const nodes: MenuNode[] = [
46
+ {
47
+ kind: "entry",
48
+ label: "loop bars",
49
+ value: score.loop ? rangeLabel(score.loop) : "the song",
50
+ placeholder: "bars, e.g. 5-6",
51
+ example: "loop 5-6",
52
+ help: "loop these bars in playback; empty plays the song",
53
+ command: (text) => {
54
+ if (!text.trim()) return "loop off";
55
+ const bars = barsText(text);
56
+ return bars ? `loop ${bars}` : undefined;
57
+ },
58
+ },
59
+ ];
60
+ if (score.loop)
61
+ nodes.push(
62
+ {
63
+ kind: "action",
64
+ label: "loop next",
65
+ command: "loop next",
66
+ help: "step the loop one length later",
67
+ },
68
+ {
69
+ kind: "action",
70
+ label: "loop prev",
71
+ command: "loop prev",
72
+ help: "step the loop one length earlier",
73
+ },
74
+ );
75
+ nodes.push(
76
+ {
77
+ kind: "entry",
78
+ label: "copy bars",
79
+ value: "",
80
+ placeholder: "bars to bar, e.g. 5-6 to 7 x2",
81
+ example: `copy ${track} 5-6 to 7 x2`,
82
+ help: "copy this track's bars; x2 tiles, insert shifts later music",
83
+ command: (text) =>
84
+ text.trim() ? `copy ${track} ${text.trim()}` : undefined,
85
+ },
86
+ {
87
+ kind: "entry",
88
+ label: "move bars",
89
+ value: "",
90
+ placeholder: "bars to bar, e.g. 5-6 to 9",
91
+ example: `move ${track} 5-6 to 9`,
92
+ help: "move this track's bars; the source empties",
93
+ command: (text) =>
94
+ text.trim() ? `move ${track} ${text.trim()}` : undefined,
95
+ },
96
+ {
97
+ kind: "entry",
98
+ label: "clear bars",
99
+ value: "",
100
+ placeholder: "bars, e.g. 5-6",
101
+ example: `clear ${track} 5-6`,
102
+ help: "empty this track's bars; the bars stay",
103
+ command: (text) => {
104
+ const bars = barsText(text);
105
+ return bars ? `clear ${track} ${bars}` : undefined;
106
+ },
107
+ },
108
+ {
109
+ kind: "entry",
110
+ label: "reverse bars",
111
+ value: "",
112
+ placeholder: "bars, e.g. 5-6",
113
+ example: `reverse ${track} 5-6`,
114
+ help: "mirror this track's bars in time",
115
+ command: (text) => {
116
+ const bars = barsText(text);
117
+ return bars ? `reverse ${track} ${bars}` : undefined;
118
+ },
119
+ },
120
+ {
121
+ kind: "entry",
122
+ label: "paste at",
123
+ value: "",
124
+ placeholder: "a bar, e.g. 9",
125
+ example: "paste at 9",
126
+ help: "lay the clipboard down (copy bars without `to` fills it)",
127
+ command: (text) =>
128
+ /^\d{1,4}$/u.test(text.trim()) ? `paste at ${text.trim()}` : undefined,
129
+ },
130
+ {
131
+ kind: "entry",
132
+ label: "insert bars",
133
+ value: "",
134
+ placeholder: "count at bar, e.g. 2 at 3",
135
+ example: "bars insert 2 at 3",
136
+ help: "add empty bars; later sections, clips, automation and tempo move right",
137
+ command: (text) =>
138
+ /^\d{1,3}\s+at\s+\d{1,4}$/u.test(text.trim())
139
+ ? `bars insert ${text.trim().replace(/\s+/gu, " ")}`
140
+ : undefined,
141
+ },
142
+ {
143
+ kind: "entry",
144
+ label: "remove bars",
145
+ value: "",
146
+ placeholder: "bars, e.g. 5-6",
147
+ example: "bars remove 5-6",
148
+ help: "cut the bars out; later music moves left",
149
+ command: (text) => {
150
+ const bars = barsText(text);
151
+ return bars ? `bars remove ${bars}` : undefined;
152
+ },
153
+ },
154
+ {
155
+ kind: "entry",
156
+ label: "jump",
157
+ value: "",
158
+ placeholder: "bar or bar.beat, e.g. 5.3",
159
+ example: "jump 5.3",
160
+ help: "move the playhead",
161
+ command: (text) =>
162
+ /^\d{1,4}(?:\.\d{1,2})?$/u.test(text.trim())
163
+ ? `jump ${text.trim()}`
164
+ : undefined,
165
+ },
166
+ );
167
+ return nodes;
168
+ }
169
+
24
170
  /**
25
171
  * Arrange's rows after tracks (§4a): sections (each section, mark bars,
26
172
  * add section), then form and the style browser.
@@ -54,7 +200,7 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
54
200
  if (score.form.length)
55
201
  nodes.push({
56
202
  kind: "action",
57
- label: "bake form",
203
+ label: "print form to tape",
58
204
  command: "form bake",
59
205
  help: "write the form out as plain bars, then clear it",
60
206
  });
@@ -66,6 +212,28 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
66
212
  help: "play the whole song (or form) again",
67
213
  });
68
214
  }
215
+ // TAPE (op1-ux §6): every track across the bars, copy/cut/paste keys.
216
+ nodes.push({
217
+ kind: "action",
218
+ label: "tape",
219
+ command: "/tape",
220
+ help: "every track across the bars · c copy, x cut, v paste · ctrl-t",
221
+ });
222
+ // §4.7: record the loop in PLAY, one undo step per pass (r / R on TAPE).
223
+ nodes.push({
224
+ kind: "action",
225
+ label: "record the loop",
226
+ command: "keys record",
227
+ help: "play mode over the loop · each pass is one undo step · r on tape",
228
+ });
229
+ nodes.push({
230
+ kind: "menu",
231
+ id: "range",
232
+ label: "range",
233
+ detail: rangeDetail(context),
234
+ help: "loop bars, copy, move, clear, paste or reverse them, insert or remove bars",
235
+ build: rangeNodes,
236
+ });
69
237
  // The style browser: a whole song from a style (src/tui/style-menu.ts).
70
238
  nodes.push({
71
239
  kind: "menu",
package/src/tui/euclid.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * runs through the normal prompt path, so every change is one revision and
10
10
  * one undo step and the row's detail teaches the command.
11
11
  *
12
- * Keys: ↑↓ voice · ←→ (h l + -) nudge · tab / shift-tab (] [) parameter ·
12
+ * Keys: ↑↓ knob (● drum ▲ pulses ■ rotate ◆ velocity) · ←→ (h l + -) turn ·
13
+ * tab / shift-tab (] [) every parameter ·
13
14
  * digits type a value · enter add/edit · space loop · x off · f freeze ·
14
15
  * esc back.
15
16
  *
@@ -48,6 +49,8 @@ import {
48
49
  import type { PickerItem } from "../../tui/app.ts";
49
50
  import { auditionKey, type AuditionKey } from "./audition.ts";
50
51
  import type { MenuAudition } from "./menu.ts";
52
+ import { KNOB_MAPS } from "./knob-map.ts";
53
+ import { knobGlyph, KNOBS, nextKnob, type KnobIndex } from "../../tui/knobs.ts";
51
54
 
52
55
  export type EuclidContext = Readonly<{
53
56
  /** The score shown: the staged one while auditioning. */
@@ -295,6 +298,28 @@ export function ringText(row: RhythmRow | undefined): string {
295
298
  .join("");
296
299
  }
297
300
 
301
+ /**
302
+ * The four knobs (KNOB_MAPS.euclid): blue `drum` picks the row, the others
303
+ * name EUCLID_PARAMS fields.
304
+ */
305
+ const EUCLID_KNOBS = KNOB_MAPS.euclid!;
306
+
307
+ /** After the strip, the knob keys, then the way out; fitHint drops the knob keys first. */
308
+ const KNOB_KEYS = "↑↓ knob · ←→ turn · esc back · ? keys";
309
+
310
+ /** The knob that turns `param`, if one does. */
311
+ function knobOfParam(param: number): KnobIndex | undefined {
312
+ const at = EUCLID_KNOBS.indexOf(EUCLID_PARAMS[param]!.field);
313
+ return at < 0 ? undefined : (at as KnobIndex);
314
+ }
315
+
316
+ /** EUCLID_PARAMS index a knob turns; undefined for the drum knob. */
317
+ function paramOfKnob(knob: KnobIndex): number | undefined {
318
+ const field = EUCLID_KNOBS[knob];
319
+ const at = EUCLID_PARAMS.findIndex((param) => param.field === field);
320
+ return at < 0 ? undefined : at;
321
+ }
322
+
298
323
  /** Tab ] next field, shift-tab [ previous (the brackets are the old keys). */
299
324
  const isNextField = (value: string) => KEY_TAB.has(value) || value === "]";
300
325
  const isPrevField = (value: string) => KEY_BACKTAB.has(value) || value === "[";
@@ -303,6 +328,8 @@ export class EuclidEditor {
303
328
  private visible = false;
304
329
  private lane = 0;
305
330
  private param = 0;
331
+ /** True while the blue knob (which drum) has focus, not a field. */
332
+ private onDrum = true;
306
333
  private entry: string | undefined;
307
334
  /** Where Esc returns to (`menu` when opened from `/menu`). */
308
335
  private origin: string | undefined;
@@ -327,6 +354,10 @@ export class EuclidEditor {
327
354
  this.origin = origin;
328
355
  const lanes = this.lanes(context);
329
356
  const at = voice ? lanes.findIndex((lane) => lane.voice === voice) : -1;
357
+ // Opened on a named drum, the green knob (pulses) has focus; otherwise
358
+ // the blue one, to pick the drum.
359
+ this.onDrum = at < 0;
360
+ if (!this.onDrum) this.param = paramOfKnob(1) ?? 0;
330
361
  if (at >= 0) this.lane = at;
331
362
  else {
332
363
  // Land on the first voice that already has a row.
@@ -352,9 +383,14 @@ export class EuclidEditor {
352
383
  return lanes[Math.min(this.lane, lanes.length - 1)]?.voice;
353
384
  }
354
385
 
355
- /** Parameter under the cursor. */
386
+ /** Parameter under the cursor (`drum` on the blue knob). */
356
387
  get selectedParam(): string {
357
- return EUCLID_PARAMS[this.param]!.field;
388
+ return this.onDrum ? "drum" : EUCLID_PARAMS[this.param]!.field;
389
+ }
390
+
391
+ /** The knob with focus; undefined on a field no knob turns. */
392
+ get selectedKnob(): KnobIndex | undefined {
393
+ return this.onDrum ? 0 : knobOfParam(this.param);
358
394
  }
359
395
 
360
396
  key(value: string, context: EuclidContext): EuclidResult {
@@ -376,7 +412,8 @@ export class EuclidEditor {
376
412
  if (KEY_ENTER.has(value)) {
377
413
  const text = this.entry.trim();
378
414
  this.entry = undefined;
379
- if (!lane || !/^-?[0-9./tT]+$/.test(text)) return { type: "handled" };
415
+ if (this.onDrum || !lane || !/^-?[0-9./tT]+$/.test(text))
416
+ return { type: "handled" };
380
417
  return {
381
418
  type: "run",
382
419
  command: `euclid ${lane.voice} ${param.field} ${text}`,
@@ -392,21 +429,45 @@ export class EuclidEditor {
392
429
  return audition?.dirty ? { type: "revert" } : { type: "close" };
393
430
  const loopKey = audition ? auditionKey(value) : undefined;
394
431
  if (loopKey) return { type: "loop", key: loopKey };
432
+ // ↑↓ pick a knob (design §8.7); from a field no knob turns, ↑ lands
433
+ // on orange and ↓ on blue.
395
434
  if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
396
- if (lanes.length)
397
- this.lane =
398
- (this.lane + (KEY_UP.has(value) ? -1 : 1) + lanes.length) %
399
- lanes.length;
435
+ const up = KEY_UP.has(value);
436
+ const from = this.selectedKnob;
437
+ const knob =
438
+ from === undefined
439
+ ? up
440
+ ? 3
441
+ : 0
442
+ : nextKnob(from, up ? -1 : 1, (index) =>
443
+ index === 0 ? true : paramOfKnob(index) !== undefined,
444
+ );
445
+ this.onDrum = knob === 0;
446
+ if (!this.onDrum) this.param = paramOfKnob(knob) ?? this.param;
400
447
  return { type: "handled" };
401
448
  }
449
+ // Tab walks every field (the full list behind the knobs).
402
450
  if (isNextField(value) || isPrevField(value)) {
403
451
  const direction = isNextField(value) ? 1 : -1;
404
- this.param =
405
- (this.param + direction + EUCLID_PARAMS.length) % EUCLID_PARAMS.length;
452
+ if (this.onDrum) {
453
+ this.onDrum = false;
454
+ this.param = direction > 0 ? 0 : EUCLID_PARAMS.length - 1;
455
+ } else
456
+ this.param =
457
+ (this.param + direction + EUCLID_PARAMS.length) %
458
+ EUCLID_PARAMS.length;
406
459
  return { type: "handled" };
407
460
  }
408
461
  if (!lane) return { type: "handled" };
409
462
  if (value === " ") return { type: "audition", voice: lane.voice };
463
+ if (this.onDrum && (KEY_LEFT.has(value) || KEY_RIGHT.has(value))) {
464
+ // The blue knob turns which drum row has focus.
465
+ if (lanes.length)
466
+ this.lane =
467
+ (this.lane + (KEY_RIGHT.has(value) ? 1 : -1) + lanes.length) %
468
+ lanes.length;
469
+ return { type: "handled" };
470
+ }
410
471
  if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
411
472
  const direction = KEY_RIGHT.has(value) ? 1 : -1;
412
473
  const row: RhythmRow = lane.row ?? { voice: lane.voice };
@@ -427,7 +488,7 @@ export class EuclidEditor {
427
488
  audition: lane.voice,
428
489
  };
429
490
  }
430
- if (/^[0-9]$/.test(value)) {
491
+ if (/^[0-9]$/.test(value) && !this.onDrum) {
431
492
  this.entry = value;
432
493
  return { type: "handled" };
433
494
  }
@@ -439,7 +500,7 @@ export class EuclidEditor {
439
500
  command: `euclid ${lane.voice} ${RHYTHM_DEFAULTS.pulses} ${RHYTHM_DEFAULTS.steps}`,
440
501
  audition: lane.voice,
441
502
  };
442
- this.entry = "";
503
+ if (!this.onDrum) this.entry = "";
443
504
  return { type: "handled" };
444
505
  }
445
506
  if (KEY_RESET.has(value))
@@ -454,6 +515,23 @@ export class EuclidEditor {
454
515
  return { type: "handled" };
455
516
  }
456
517
 
518
+ /**
519
+ * The knob strip for the hint row: `●›kick ▲ pulses 4 ■ rotate 0 ◆ velocity
520
+ * 0.8`, the focused knob marked `›`. Glyphs carry which knob is which.
521
+ */
522
+ private strip(drum: string | undefined, row: RhythmRow | undefined): string {
523
+ const focused = this.selectedKnob;
524
+ return KNOBS.map((knob) => {
525
+ const mark = knob.index === focused ? "›" : " ";
526
+ if (knob.index === 0) return `${knob.glyph}${mark}${drum ?? "·"}`;
527
+ const at = paramOfKnob(knob.index);
528
+ if (at === undefined) return `${knob.glyph} ·`;
529
+ const param = EUCLID_PARAMS[at]!;
530
+ const shown = row ? formatValue(param.value(row)) : "—";
531
+ return `${knob.glyph}${mark}${param.label} ${shown}`;
532
+ }).join(" ");
533
+ }
534
+
457
535
  view(context: EuclidContext): EuclidView {
458
536
  const lanes = this.lanes(context);
459
537
  const index = Math.max(0, Math.min(this.lane, lanes.length - 1));
@@ -489,7 +567,10 @@ export class EuclidEditor {
489
567
  const voice = lanes[index]?.voice ?? "";
490
568
  const row = lanes[index]?.row;
491
569
  const value = row ? formatValue(param.value(row)) : "—";
492
- let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${param.label} ${value}`;
570
+ const focus = this.onDrum
571
+ ? `${knobGlyph(0, true)} drum`
572
+ : `${param.label} ${value}`;
573
+ let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${focus}`;
493
574
  if (this.entry !== undefined) title += ` · ${param.label}: ${this.entry}▏`;
494
575
  if (audition?.dirty) title = `● ${title}`;
495
576
  if (audition?.status && this.entry === undefined)
@@ -499,7 +580,7 @@ export class EuclidEditor {
499
580
  ? HINTS.typing
500
581
  : audition && (audition.looping || audition.dirty)
501
582
  ? audition.hint
502
- : HINTS.euclid;
583
+ : `${this.strip(lanes[index]?.label.trim(), row)} · ${KNOB_KEYS}`;
503
584
  return {
504
585
  title,
505
586
  items: items.length