@hraness/dawg 0.4.1 → 0.5.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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
@@ -68,6 +68,11 @@ export type AuditionHost = {
68
68
  * settings screen plays a progression with its settings this way.
69
69
  */
70
70
  phrase?(showing: "A" | "B"): PreviewOptions["phrase"];
71
+ /**
72
+ * The drive the song's master solved for its target on the full mix (the
73
+ * last song loop), so previews hear a fixed master gain.
74
+ */
75
+ masterGainDb?(): number | undefined;
71
76
  };
72
77
 
73
78
  export type AuditionOptions = Readonly<{
@@ -108,6 +113,8 @@ export class Audition {
108
113
  private staged: Staged[] = [];
109
114
  private work: Promise<unknown> = Promise.resolve();
110
115
  private rendering = false;
116
+ /** The song master's solved drive when the loop started (see host). */
117
+ private masterGainDb: number | undefined;
111
118
  private dirty = false;
112
119
  private pendingKeyAt: number | undefined;
113
120
  private lastRenderAt = -Infinity;
@@ -166,6 +173,7 @@ export class Audition {
166
173
 
167
174
  public start(): void {
168
175
  if (this.looping) return;
176
+ this.masterGainDb = this.host.masterGainDb?.();
169
177
  this.looping = true;
170
178
  this.lastRenderAt = -Infinity;
171
179
  this.request();
@@ -192,6 +200,18 @@ export class Audition {
192
200
  this.host.changed?.();
193
201
  }
194
202
 
203
+ /**
204
+ * Whether the loop plays the full mix: the `c` toggle, or forced while a
205
+ * master edit is staged, because the master works on the whole mix and a
206
+ * solo track says nothing about its loudness.
207
+ */
208
+ public get inContext(): boolean {
209
+ return (
210
+ this.context ||
211
+ this.staged.some((entry) => MASTER_COMMAND.test(entry.command))
212
+ );
213
+ }
214
+
195
215
  /** A/B; a no-op (stays on B) while nothing is staged. */
196
216
  public toggleAB(): void {
197
217
  if (!this.dirtyEdits) {
@@ -412,10 +432,18 @@ export class Audition {
412
432
  if (!this.looping || this.rendering) return;
413
433
  this.cancelTimer();
414
434
  const phrase = this.host.phrase?.(this.dirtyEdits ? this.showing : "A");
435
+ // A staged master edit re-solves its target on the mix; otherwise the
436
+ // master plays at the song's solved drive, so a track edit is audible.
437
+ const masterStaged = this.staged.some((entry) =>
438
+ MASTER_COMMAND.test(entry.command),
439
+ );
415
440
  const preview = previewScore(this.sounding, this.trackId, {
416
- context: this.context,
441
+ context: this.inContext,
417
442
  beat: this.host.beat?.() ?? 0,
418
443
  ...(phrase ? { phrase } : {}),
444
+ ...(this.masterGainDb !== undefined && !masterStaged
445
+ ? { masterGainDb: this.masterGainDb }
446
+ : {}),
419
447
  });
420
448
  if (!preview) return;
421
449
  this.rendering = true;
@@ -451,7 +479,7 @@ export class Audition {
451
479
  public status(): string | undefined {
452
480
  if (!this.looping && !this.dirtyEdits && !this.fetching) return undefined;
453
481
  const parts: string[] = [];
454
- if (this.looping) parts.push(`♪ ${this.context ? "in context" : "solo"}`);
482
+ if (this.looping) parts.push(`♪ ${this.inContext ? "in context" : "solo"}`);
455
483
  else parts.push("loop off");
456
484
  if (this.fetching) parts.push("fetching…");
457
485
  if (this.dirtyEdits)
@@ -500,13 +528,18 @@ export function auditionKey(value: string): AuditionKey | undefined {
500
528
  * before.
501
529
  */
502
530
  const STAGEABLE =
503
- /^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid)\s+\S|pack\s+use\s+\S)/i;
531
+ /^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master)\s+\S|pack\s+use\s+\S)/i;
504
532
  /** Subcommands that list or show instead of changing the sound. */
505
- const READ_ONLY = /^\/?\S+\s+(list|show|info|help)\s*$/i;
533
+ const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter)\s*$/i;
534
+ /** `master <unit>` alone only shows the unit. */
535
+ const MASTER_SHOW = /^\/?master\s+(eq|glue|tape|width|limiter)\s*$/i;
536
+ const MASTER_COMMAND = /^\/?master\s/i;
506
537
 
507
538
  export function isStageable(command: string): boolean {
508
539
  const text = command.trim();
509
- return STAGEABLE.test(text) && !READ_ONLY.test(text);
540
+ return (
541
+ STAGEABLE.test(text) && !READ_ONLY.test(text) && !MASTER_SHOW.test(text)
542
+ );
510
543
  }
511
544
 
512
545
  /**
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Fader mode: the keys and mouse gestures of the fader drawer
3
+ * (tui/drawer.ts), as pure functions over a list of fields.
4
+ *
5
+ * A field is a number (with a range, a step function and a formatter taken
6
+ * from the menu row, which in turn takes them from core/params.ts, the fx
7
+ * specs and the automation lanes) or a choice (a segmented selector). Every
8
+ * change is a command: the caller stages it on the audition loop, keyed by
9
+ * field, so repeated nudges replace one staged edit and Enter keeps them
10
+ * all as one revision.
11
+ */
12
+ import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
13
+
14
+ export type FaderNumber = Readonly<{
15
+ kind: "number";
16
+ label: string;
17
+ value: number | undefined;
18
+ min: number;
19
+ max: number;
20
+ step: (value: number, direction: 1 | -1) => number;
21
+ format: (value: number) => string;
22
+ command: (value: number) => string;
23
+ /** Typed text → command; undefined when it does not parse or fit. */
24
+ parse: (text: string) => string | undefined;
25
+ /** The command `0` / `d` runs to put the value back to its default. */
26
+ reset?: string | undefined;
27
+ /** Starting value for a nudge while off. */
28
+ start?: number | undefined;
29
+ off?: string | undefined;
30
+ }>;
31
+
32
+ export type FaderChoice = Readonly<{
33
+ kind: "choice";
34
+ label: string;
35
+ value: string;
36
+ options: readonly string[];
37
+ command: (option: string) => string;
38
+ }>;
39
+
40
+ export type FaderSpec = FaderNumber | FaderChoice;
41
+
42
+ export interface FaderState {
43
+ /** The focused field, by label (labels survive rebuilds; indexes may not). */
44
+ label: string;
45
+ /** Digits typed for the focused field. */
46
+ typing?: string | undefined;
47
+ }
48
+
49
+ export type FaderResult =
50
+ | { type: "handled" }
51
+ /** Set a field: stage `command`, replacing the field's previous edit. */
52
+ | { type: "set"; command: string; key: string }
53
+ | { type: "keep" }
54
+ | { type: "revert" }
55
+ /** Leave the drawer (nothing staged, or after keep / revert). */
56
+ | { type: "close" }
57
+ | { type: "audition"; key: "loop" | "ab" | "context" }
58
+ | { type: "pass" };
59
+
60
+ /** The key a field's staged edit is filed under. */
61
+ export function faderKey(label: string): string {
62
+ return `fader:${label}`;
63
+ }
64
+
65
+ /** A wide range of positive values reads (and drags) on a log scale. */
66
+ function logScale(spec: FaderNumber): boolean {
67
+ return spec.min > 0 && spec.max / spec.min >= 100;
68
+ }
69
+
70
+ /** Where `value` sits along the bar, 0…1. */
71
+ export function faderPosition(spec: FaderNumber, value: number): number {
72
+ if (spec.max <= spec.min) return 0;
73
+ const clamped = Math.min(spec.max, Math.max(spec.min, value));
74
+ if (logScale(spec))
75
+ return Math.log(clamped / spec.min) / Math.log(spec.max / spec.min);
76
+ return (clamped - spec.min) / (spec.max - spec.min);
77
+ }
78
+
79
+ /**
80
+ * The value at `position` (0…1), snapped to the field's own step grid: the
81
+ * nearest value its step function can reach from the minimum, so a drag
82
+ * lands on the same values the keys do.
83
+ */
84
+ export function faderValueAt(spec: FaderNumber, position: number): number {
85
+ const p = Math.min(1, Math.max(0, position));
86
+ if (p <= 0) return spec.min;
87
+ if (p >= 1) return spec.max;
88
+ const raw = logScale(spec)
89
+ ? spec.min * (spec.max / spec.min) ** p
90
+ : spec.min + p * (spec.max - spec.min);
91
+ return snap(spec, raw);
92
+ }
93
+
94
+ /** The nearest step-reachable value to `raw` (step size near `raw`). */
95
+ function snap(spec: FaderNumber, raw: number): number {
96
+ const up = spec.step(raw, 1);
97
+ const down = spec.step(raw, -1);
98
+ // Linear steps round onto the grid; log steps move by a ratio. Either
99
+ // way the step size near `raw` bounds the precision worth keeping.
100
+ const size = Math.max(
101
+ 1e-9,
102
+ Math.min(Math.abs(up - raw), Math.abs(raw - down)) ||
103
+ Math.abs(up - down) / 2,
104
+ );
105
+ const decimals = Math.max(0, Math.min(4, Math.ceil(-Math.log10(size))));
106
+ const candidates = [down, up, Number(raw.toFixed(decimals))];
107
+ // Prefer an exact grid value when one is within half a step.
108
+ let best = candidates[2]!;
109
+ for (const candidate of [down, up])
110
+ if (Math.abs(candidate - raw) <= size / 2 + 1e-9) best = candidate;
111
+ return Math.min(spec.max, Math.max(spec.min, best));
112
+ }
113
+
114
+ /** Fine (a tenth of a step), normal, coarse (five steps) or page (twenty). */
115
+ export type StepSize = "fine" | "normal" | "coarse" | "page";
116
+
117
+ export function stepValue(
118
+ spec: FaderNumber,
119
+ direction: 1 | -1,
120
+ size: StepSize = "normal",
121
+ ): number {
122
+ if (spec.value === undefined) return spec.start ?? spec.min;
123
+ const clamp = (value: number) =>
124
+ Math.min(spec.max, Math.max(spec.min, value));
125
+ if (size === "fine") {
126
+ const next = spec.step(spec.value, direction);
127
+ const delta = (next - spec.value) / 10;
128
+ if (Math.abs(delta) < 1e-9) return spec.value;
129
+ const decimals = Math.max(
130
+ 0,
131
+ Math.min(4, Math.ceil(-Math.log10(Math.abs(delta)))),
132
+ );
133
+ return clamp(Number((spec.value + delta).toFixed(decimals)));
134
+ }
135
+ const times = size === "coarse" ? 5 : size === "page" ? 20 : 1;
136
+ let value = spec.value;
137
+ for (let index = 0; index < times; index += 1) {
138
+ const next = clamp(spec.step(value, direction));
139
+ if (Math.abs(next - value) < 1e-12) break;
140
+ value = next;
141
+ }
142
+ return value;
143
+ }
144
+
145
+ const LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
146
+ const RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
147
+ const COARSE_LEFT = new Set(["\u001b[1;2D", "{"]);
148
+ const COARSE_RIGHT = new Set(["\u001b[1;2C", "}"]);
149
+ const FINE_LEFT = new Set(["\u001b[1;3D", "\u001b[1;5D", "\u001bb", "["]);
150
+ const FINE_RIGHT = new Set(["\u001b[1;3C", "\u001b[1;5C", "\u001bf", "]"]);
151
+ const UP = new Set(["\u001b[A", "\u001bOA", "k", "\u001b[Z"]);
152
+ const DOWN = new Set(["\u001b[B", "\u001bOB", "j", "\t"]);
153
+ const PAGE_UP = "\u001b[5~";
154
+ const PAGE_DOWN = "\u001b[6~";
155
+ const HOME = new Set(["\u001b[H", "\u001bOH", "\u001b[1~"]);
156
+ const END = new Set(["\u001b[F", "\u001bOF", "\u001b[4~"]);
157
+ const ENTER = new Set(["\r", "\n"]);
158
+ const BACKSPACE = new Set(["\u007f", "\b"]);
159
+
160
+ export interface FaderKeyOptions {
161
+ /** Edits are staged (Enter keeps, Esc reverts). */
162
+ dirty: boolean;
163
+ /** The window hosts the audition loop (space, a, c). */
164
+ audition: boolean;
165
+ }
166
+
167
+ /** Index of the focused field (the first when its label is gone). */
168
+ export function focusIndex(
169
+ state: FaderState,
170
+ fields: readonly FaderSpec[],
171
+ ): number {
172
+ return Math.max(
173
+ 0,
174
+ fields.findIndex((field) => field.label === state.label),
175
+ );
176
+ }
177
+
178
+ function setNumber(spec: FaderNumber, value: number): FaderResult {
179
+ if (spec.value !== undefined && Math.abs(value - spec.value) < 1e-9)
180
+ return { type: "handled" };
181
+ return {
182
+ type: "set",
183
+ command: spec.command(value),
184
+ key: faderKey(spec.label),
185
+ };
186
+ }
187
+
188
+ function setChoice(spec: FaderChoice, index: number): FaderResult {
189
+ const option =
190
+ spec.options[Math.min(spec.options.length - 1, Math.max(0, index))];
191
+ if (option === undefined || option === spec.value) return { type: "handled" };
192
+ return {
193
+ type: "set",
194
+ command: spec.command(option),
195
+ key: faderKey(spec.label),
196
+ };
197
+ }
198
+
199
+ /** One key in fader mode. Mutates `state` (focus, typing). */
200
+ export function faderKeyPress(
201
+ state: FaderState,
202
+ fields: readonly FaderSpec[],
203
+ value: string,
204
+ options: FaderKeyOptions,
205
+ ): FaderResult {
206
+ if (fields.length === 0) return { type: "close" };
207
+ // Ctrl-C, Ctrl-L and `?` (the keys panel) belong to the app.
208
+ if (value === "\u0003" || value === "\u000c") return { type: "pass" };
209
+ if (value === "?" && state.typing === undefined) return { type: "pass" };
210
+ const index = focusIndex(state, fields);
211
+ const field = fields[index]!;
212
+ state.label = field.label;
213
+ if (state.typing !== undefined) {
214
+ if (value === "\u001b") {
215
+ state.typing = undefined;
216
+ return { type: "handled" };
217
+ }
218
+ if (BACKSPACE.has(value)) {
219
+ state.typing = state.typing.slice(0, -1);
220
+ if (!state.typing) state.typing = undefined;
221
+ return { type: "handled" };
222
+ }
223
+ if (ENTER.has(value) || value === "\t") {
224
+ const text = state.typing;
225
+ state.typing = undefined;
226
+ const command = field.kind === "number" ? field.parse(text) : undefined;
227
+ return command
228
+ ? { type: "set", command, key: faderKey(field.label) }
229
+ : { type: "handled" };
230
+ }
231
+ if (/^[0-9.\-]$/.test(value)) {
232
+ state.typing = (state.typing + value).slice(0, 16);
233
+ return { type: "handled" };
234
+ }
235
+ return { type: "handled" };
236
+ }
237
+ if (value === "\u001b")
238
+ return options.dirty ? { type: "revert" } : { type: "close" };
239
+ if (ENTER.has(value))
240
+ return options.dirty ? { type: "keep" } : { type: "close" };
241
+ const move = UP.has(value) ? -1 : DOWN.has(value) ? 1 : 0;
242
+ if (move) {
243
+ state.label = fields[(index + move + fields.length) % fields.length]!.label;
244
+ return { type: "handled" };
245
+ }
246
+ if (options.audition) {
247
+ if (value === " ") return { type: "audition", key: "loop" };
248
+ if (value === "a") return { type: "audition", key: "ab" };
249
+ if (value === "c") return { type: "audition", key: "context" };
250
+ }
251
+ if (field.kind === "choice") {
252
+ const at = Math.max(0, field.options.indexOf(field.value));
253
+ if (LEFT.has(value) || FINE_LEFT.has(value) || COARSE_LEFT.has(value))
254
+ return setChoice(field, at - 1);
255
+ if (RIGHT.has(value) || FINE_RIGHT.has(value) || COARSE_RIGHT.has(value))
256
+ return setChoice(field, at + 1);
257
+ if (HOME.has(value) || value === PAGE_DOWN) return setChoice(field, 0);
258
+ if (END.has(value) || value === PAGE_UP)
259
+ return setChoice(field, field.options.length - 1);
260
+ // 1…9 picks an option by number.
261
+ if (/^[1-9]$/.test(value)) return setChoice(field, Number(value) - 1);
262
+ return { type: "handled" };
263
+ }
264
+ const step = (direction: 1 | -1, size: StepSize) =>
265
+ setNumber(field, stepValue(field, direction, size));
266
+ if (LEFT.has(value)) return step(-1, "normal");
267
+ if (RIGHT.has(value)) return step(1, "normal");
268
+ if (COARSE_LEFT.has(value)) return step(-1, "coarse");
269
+ if (COARSE_RIGHT.has(value)) return step(1, "coarse");
270
+ if (FINE_LEFT.has(value)) return step(-1, "fine");
271
+ if (FINE_RIGHT.has(value)) return step(1, "fine");
272
+ if (value === PAGE_UP) return step(1, "page");
273
+ if (value === PAGE_DOWN) return step(-1, "page");
274
+ if (HOME.has(value)) return setNumber(field, field.min);
275
+ if (END.has(value)) return setNumber(field, field.max);
276
+ if (value === "0" || value === "d") {
277
+ const reset =
278
+ field.reset ??
279
+ (field.start !== undefined ? field.command(field.start) : undefined);
280
+ return reset && field.value !== undefined
281
+ ? { type: "set", command: reset, key: faderKey(field.label) }
282
+ : { type: "handled" };
283
+ }
284
+ if (/^[1-9.]$/.test(value)) {
285
+ state.typing = value === "." ? "0." : value;
286
+ return { type: "handled" };
287
+ }
288
+ return { type: "handled" };
289
+ }
290
+
291
+ /** A click or drag on a field's bar at `position` (0…1). */
292
+ export function faderSetPosition(
293
+ state: FaderState,
294
+ field: FaderSpec | undefined,
295
+ position: number,
296
+ ): FaderResult {
297
+ if (!field) return { type: "handled" };
298
+ state.label = field.label;
299
+ state.typing = undefined;
300
+ if (field.kind === "choice")
301
+ return setChoice(
302
+ field,
303
+ Math.round(position * Math.max(0, field.options.length - 1)),
304
+ );
305
+ return setNumber(field, faderValueAt(field, position));
306
+ }
307
+
308
+ /** A [−] / [+] click or a wheel notch on a field. */
309
+ export function faderStep(
310
+ state: FaderState,
311
+ field: FaderSpec | undefined,
312
+ direction: 1 | -1,
313
+ size: StepSize = "normal",
314
+ ): FaderResult {
315
+ if (!field) return { type: "handled" };
316
+ state.label = field.label;
317
+ state.typing = undefined;
318
+ if (field.kind === "choice") {
319
+ const at = Math.max(0, field.options.indexOf(field.value));
320
+ return setChoice(field, at + direction);
321
+ }
322
+ return setNumber(field, stepValue(field, direction, size));
323
+ }
324
+
325
+ /** A click on one option of a choice field. */
326
+ export function faderChoose(
327
+ state: FaderState,
328
+ field: FaderSpec | undefined,
329
+ option: number,
330
+ ): FaderResult {
331
+ if (!field || field.kind !== "choice") return { type: "handled" };
332
+ state.label = field.label;
333
+ state.typing = undefined;
334
+ return setChoice(field, option);
335
+ }
336
+
337
+ const HINT =
338
+ "←→ adjust · ⇧←→ coarse · [ ] fine · ↑↓ param · 0-9 type · d default · enter keep · esc revert";
339
+
340
+ /** The drawer's paint model for `fields` (staged) beside `committed`. */
341
+ export function drawerView(
342
+ state: FaderState,
343
+ fields: readonly FaderSpec[],
344
+ committed: readonly FaderSpec[],
345
+ options: {
346
+ title: string;
347
+ dirty: boolean;
348
+ status?: string | undefined;
349
+ hint?: string;
350
+ },
351
+ ): DrawerView {
352
+ const before = new Map(committed.map((field) => [field.label, field]));
353
+ const drawn: DrawerField[] = fields.map((field) => {
354
+ const was = before.get(field.label);
355
+ if (field.kind === "choice") {
356
+ const index = Math.max(0, field.options.indexOf(field.value));
357
+ const committedIndex =
358
+ was?.kind === "choice" && was.value !== field.value
359
+ ? field.options.indexOf(was.value)
360
+ : undefined;
361
+ return {
362
+ kind: "choice",
363
+ label: field.label,
364
+ options: field.options,
365
+ index,
366
+ committedIndex:
367
+ committedIndex !== undefined && committedIndex >= 0
368
+ ? committedIndex
369
+ : undefined,
370
+ };
371
+ }
372
+ const text =
373
+ field.value === undefined
374
+ ? (field.off ?? "off")
375
+ : field.format(field.value);
376
+ const wasText =
377
+ was?.kind === "number"
378
+ ? was.value === undefined
379
+ ? (was.off ?? "off")
380
+ : was.format(was.value)
381
+ : undefined;
382
+ const changed = wasText !== undefined && wasText !== text;
383
+ return {
384
+ kind: "number",
385
+ label: field.label,
386
+ text,
387
+ committed: changed ? wasText : undefined,
388
+ position:
389
+ field.value === undefined
390
+ ? undefined
391
+ : faderPosition(field, field.value),
392
+ committedPosition:
393
+ changed && was?.kind === "number" && was.value !== undefined
394
+ ? faderPosition(field, was.value)
395
+ : undefined,
396
+ minText: field.format(field.min),
397
+ maxText: field.format(field.max),
398
+ };
399
+ });
400
+ return {
401
+ title: options.title,
402
+ fields: drawn,
403
+ focus: focusIndex(state, fields),
404
+ typing: state.typing,
405
+ dirty: options.dirty,
406
+ status: options.status,
407
+ hint: options.hint ?? HINT,
408
+ };
409
+ }