@hraness/dawg 0.6.1 → 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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/src/tui/fader.ts CHANGED
@@ -9,7 +9,27 @@
9
9
  * field, so repeated nudges replace one staged edit and Enter keeps them
10
10
  * all as one revision.
11
11
  */
12
- import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
12
+ import type { DrawerAside, DrawerField, DrawerView } from "../../tui/drawer.ts";
13
+ import type { KnobIndex } from "../../tui/knobs.ts";
14
+ import {
15
+ KEY_BACKSPACE,
16
+ KEY_BACKTAB,
17
+ KEY_COARSE_LEFT,
18
+ KEY_COARSE_RIGHT,
19
+ KEY_DOWN,
20
+ KEY_END,
21
+ KEY_ENTER,
22
+ KEY_FINE_LEFT,
23
+ KEY_FINE_RIGHT,
24
+ KEY_HOME,
25
+ KEY_LEFT,
26
+ KEY_PAGE_DOWN,
27
+ KEY_PAGE_UP,
28
+ KEY_RESET,
29
+ KEY_RIGHT,
30
+ KEY_TAB,
31
+ KEY_UP,
32
+ } from "../../tui/grammar.ts";
13
33
 
14
34
  export type FaderNumber = Readonly<{
15
35
  kind: "number";
@@ -22,7 +42,7 @@ export type FaderNumber = Readonly<{
22
42
  command: (value: number) => string;
23
43
  /** Typed text → command; undefined when it does not parse or fit. */
24
44
  parse: (text: string) => string | undefined;
25
- /** The command `0` / `d` runs to put the value back to its default. */
45
+ /** The command `x` / `d` / Delete runs to put the value back. */
26
46
  reset?: string | undefined;
27
47
  /** Starting value for a nudge while off. */
28
48
  start?: number | undefined;
@@ -44,6 +64,8 @@ export interface FaderState {
44
64
  label: string;
45
65
  /** Digits typed for the focused field. */
46
66
  typing?: string | undefined;
67
+ /** The field that just snapped onto a detent: flashes for one frame. */
68
+ flash?: string | undefined;
47
69
  }
48
70
 
49
71
  export type FaderResult =
@@ -111,6 +133,85 @@ function snap(spec: FaderNumber, raw: number): number {
111
133
  return Math.min(spec.max, Math.max(spec.min, best));
112
134
  }
113
135
 
136
+ /**
137
+ * Detents: values a fader catches on its way past, so the common resting
138
+ * points are easy to hit by feel. Volume 1 (0 dB), pan 0 (center), whole
139
+ * BPM, mix 0 / 0.5 / 1 and filter cutoffs on the octaves of A (27.5 Hz,
140
+ * 55 Hz … 14080 Hz). Read off the field's command, so every row that runs
141
+ * `volume`, `pan`, `tempo`, `… mix` or a cutoff gets them.
142
+ */
143
+ export type Detents = readonly number[] | "integer";
144
+
145
+ /** How close (as a share of the bar) a value must be to catch a detent. */
146
+ export const DETENT_REACH = 0.02;
147
+
148
+ const A_OCTAVES = Object.freeze(
149
+ Array.from({ length: 10 }, (_, octave) => 27.5 * 2 ** octave),
150
+ );
151
+
152
+ export function detentsFor(spec: FaderNumber): Detents | undefined {
153
+ const probe = spec.value ?? spec.start ?? spec.min;
154
+ const command = spec.command(probe).replace(/\s+\S+$/, "");
155
+ const words = command.split(/\s+/);
156
+ const verb = words[0]?.replace(/^\//, "");
157
+ if (verb === "tempo" && words.length === 1) return "integer";
158
+ if (words.includes("volume")) return [1];
159
+ if (words.includes("pan")) return [0];
160
+ if (words.at(-1) === "mix") return [0, 0.5, 1];
161
+ if (/^(cutoff|lpf|hpf)$/.test(words.at(-1) ?? "")) return A_OCTAVES;
162
+ return undefined;
163
+ }
164
+
165
+ /** The detents of `spec` that fall inside its range. */
166
+ function detentList(spec: FaderNumber, detents: Detents): number[] {
167
+ if (detents === "integer") return [];
168
+ return detents.filter((d) => d >= spec.min - 1e-9 && d <= spec.max + 1e-9);
169
+ }
170
+
171
+ /**
172
+ * `value` caught by a detent, or undefined when none is in reach. With
173
+ * `from` (a key step) a detent also catches a step that jumps over it or
174
+ * lands near it while moving toward it, so leaving a detent never sticks.
175
+ */
176
+ export function detentSnap(
177
+ spec: FaderNumber,
178
+ value: number,
179
+ from?: number,
180
+ ): number | undefined {
181
+ const detents = detentsFor(spec);
182
+ if (!detents) return undefined;
183
+ if (detents === "integer") {
184
+ const whole = Math.min(spec.max, Math.max(spec.min, Math.round(value)));
185
+ return Math.abs(whole - value) < 1e-9 ? undefined : whole;
186
+ }
187
+ const at = (v: number) => faderPosition(spec, v);
188
+ let best: number | undefined;
189
+ for (const detent of detentList(spec, detents)) {
190
+ // Landing on a detent exactly counts as a catch (it flashes) unless
191
+ // the value was already there.
192
+ if (Math.abs(detent - value) < 1e-9)
193
+ return from !== undefined && Math.abs(detent - from) > 1e-9
194
+ ? detent
195
+ : undefined;
196
+ const near = Math.abs(at(value) - at(detent)) <= DETENT_REACH;
197
+ let caught = near;
198
+ if (from !== undefined) {
199
+ const toward = Math.sign(detent - from) === Math.sign(value - from);
200
+ const crossed =
201
+ (from < detent && detent < value) || (value < detent && detent < from);
202
+ caught = Math.abs(detent - from) > 1e-9 && (crossed || (near && toward));
203
+ }
204
+ if (
205
+ caught &&
206
+ (best === undefined ||
207
+ Math.abs(at(detent) - at(from ?? value)) <
208
+ Math.abs(at(best) - at(from ?? value)))
209
+ )
210
+ best = detent;
211
+ }
212
+ return best;
213
+ }
214
+
114
215
  /** Fine (a tenth of a step), normal, coarse (five steps) or page (twenty). */
115
216
  export type StepSize = "fine" | "normal" | "coarse" | "page";
116
217
 
@@ -142,20 +243,9 @@ export function stepValue(
142
243
  return value;
143
244
  }
144
245
 
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"]);
246
+ /** ↑ and shift-tab: the previous param; ↓ and tab: the next. */
247
+ const isUp = (value: string) => KEY_UP.has(value) || KEY_BACKTAB.has(value);
248
+ const isDown = (value: string) => KEY_DOWN.has(value) || KEY_TAB.has(value);
159
249
 
160
250
  export interface FaderKeyOptions {
161
251
  /** Edits are staged (Enter keeps, Esc reverts). */
@@ -185,6 +275,32 @@ function setNumber(spec: FaderNumber, value: number): FaderResult {
185
275
  };
186
276
  }
187
277
 
278
+ /**
279
+ * Set `value`, caught by a detent unless the step is fine. `from` is the
280
+ * value a key step left (undefined for a drag or a click on the bar).
281
+ */
282
+ function setSnapped(
283
+ state: FaderState,
284
+ spec: FaderNumber,
285
+ value: number,
286
+ from: number | undefined,
287
+ size: StepSize | "drag",
288
+ ): FaderResult {
289
+ const snapped =
290
+ size === "fine"
291
+ ? undefined
292
+ : size === "drag"
293
+ ? (detentSnap(spec, value) ??
294
+ (spec.value !== undefined &&
295
+ detentSnap(spec, value, spec.value) === value
296
+ ? value
297
+ : undefined))
298
+ : detentSnap(spec, value, from);
299
+ const result = setNumber(spec, snapped ?? value);
300
+ if (snapped !== undefined && result.type === "set") state.flash = spec.label;
301
+ return result;
302
+ }
303
+
188
304
  function setChoice(spec: FaderChoice, index: number): FaderResult {
189
305
  const option =
190
306
  spec.options[Math.min(spec.options.length - 1, Math.max(0, index))];
@@ -215,12 +331,12 @@ export function faderKeyPress(
215
331
  state.typing = undefined;
216
332
  return { type: "handled" };
217
333
  }
218
- if (BACKSPACE.has(value)) {
334
+ if (KEY_BACKSPACE.has(value)) {
219
335
  state.typing = state.typing.slice(0, -1);
220
336
  if (!state.typing) state.typing = undefined;
221
337
  return { type: "handled" };
222
338
  }
223
- if (ENTER.has(value) || value === "\t") {
339
+ if (KEY_ENTER.has(value) || KEY_TAB.has(value)) {
224
340
  const text = state.typing;
225
341
  state.typing = undefined;
226
342
  const command = field.kind === "number" ? field.parse(text) : undefined;
@@ -236,9 +352,9 @@ export function faderKeyPress(
236
352
  }
237
353
  if (value === "\u001b")
238
354
  return options.dirty ? { type: "revert" } : { type: "close" };
239
- if (ENTER.has(value))
355
+ if (KEY_ENTER.has(value))
240
356
  return options.dirty ? { type: "keep" } : { type: "close" };
241
- const move = UP.has(value) ? -1 : DOWN.has(value) ? 1 : 0;
357
+ const move = isUp(value) ? -1 : isDown(value) ? 1 : 0;
242
358
  if (move) {
243
359
  state.label = fields[(index + move + fields.length) % fields.length]!.label;
244
360
  return { type: "handled" };
@@ -250,30 +366,46 @@ export function faderKeyPress(
250
366
  }
251
367
  if (field.kind === "choice") {
252
368
  const at = Math.max(0, field.options.indexOf(field.value));
253
- if (LEFT.has(value) || FINE_LEFT.has(value) || COARSE_LEFT.has(value))
369
+ if (
370
+ KEY_LEFT.has(value) ||
371
+ KEY_FINE_LEFT.has(value) ||
372
+ KEY_COARSE_LEFT.has(value)
373
+ )
254
374
  return setChoice(field, at - 1);
255
- if (RIGHT.has(value) || FINE_RIGHT.has(value) || COARSE_RIGHT.has(value))
375
+ if (
376
+ KEY_RIGHT.has(value) ||
377
+ KEY_FINE_RIGHT.has(value) ||
378
+ KEY_COARSE_RIGHT.has(value)
379
+ )
256
380
  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)
381
+ if (KEY_HOME.has(value) || KEY_PAGE_DOWN.has(value))
382
+ return setChoice(field, 0);
383
+ if (KEY_END.has(value) || KEY_PAGE_UP.has(value))
259
384
  return setChoice(field, field.options.length - 1);
260
385
  // 1…9 picks an option by number.
261
386
  if (/^[1-9]$/.test(value)) return setChoice(field, Number(value) - 1);
262
387
  return { type: "handled" };
263
388
  }
264
389
  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") {
390
+ setSnapped(
391
+ state,
392
+ field,
393
+ stepValue(field, direction, size),
394
+ field.value,
395
+ size,
396
+ );
397
+ if (KEY_LEFT.has(value)) return step(-1, "normal");
398
+ if (KEY_RIGHT.has(value)) return step(1, "normal");
399
+ if (KEY_COARSE_LEFT.has(value)) return step(-1, "coarse");
400
+ if (KEY_COARSE_RIGHT.has(value)) return step(1, "coarse");
401
+ if (KEY_FINE_LEFT.has(value)) return step(-1, "fine");
402
+ if (KEY_FINE_RIGHT.has(value)) return step(1, "fine");
403
+ if (KEY_PAGE_UP.has(value)) return step(1, "page");
404
+ if (KEY_PAGE_DOWN.has(value)) return step(-1, "page");
405
+ if (KEY_HOME.has(value)) return setNumber(field, field.min);
406
+ if (KEY_END.has(value)) return setNumber(field, field.max);
407
+ // x, d and Delete put the value back; 0-9 and . start typing.
408
+ if (KEY_RESET.has(value)) {
277
409
  const reset =
278
410
  field.reset ??
279
411
  (field.start !== undefined ? field.command(field.start) : undefined);
@@ -281,7 +413,7 @@ export function faderKeyPress(
281
413
  ? { type: "set", command: reset, key: faderKey(field.label) }
282
414
  : { type: "handled" };
283
415
  }
284
- if (/^[1-9.]$/.test(value)) {
416
+ if (/^[0-9.]$/.test(value)) {
285
417
  state.typing = value === "." ? "0." : value;
286
418
  return { type: "handled" };
287
419
  }
@@ -302,7 +434,13 @@ export function faderSetPosition(
302
434
  field,
303
435
  Math.round(position * Math.max(0, field.options.length - 1)),
304
436
  );
305
- return setNumber(field, faderValueAt(field, position));
437
+ return setSnapped(
438
+ state,
439
+ field,
440
+ faderValueAt(field, position),
441
+ undefined,
442
+ "drag",
443
+ );
306
444
  }
307
445
 
308
446
  /** A [−] / [+] click or a wheel notch on a field. */
@@ -319,7 +457,13 @@ export function faderStep(
319
457
  const at = Math.max(0, field.options.indexOf(field.value));
320
458
  return setChoice(field, at + direction);
321
459
  }
322
- return setNumber(field, stepValue(field, direction, size));
460
+ return setSnapped(
461
+ state,
462
+ field,
463
+ stepValue(field, direction, size),
464
+ field.value,
465
+ size,
466
+ );
323
467
  }
324
468
 
325
469
  /** A click on one option of a choice field. */
@@ -334,8 +478,33 @@ export function faderChoose(
334
478
  return setChoice(field, option);
335
479
  }
336
480
 
337
- const HINT =
338
- "←→ adjust · ⇧←→ coarse · [ ] fine · ↑↓ param · 0-9 type · d default · enter keep · esc revert";
481
+ /**
482
+ * Most important first: fitHint trims the middle from the right, so at 80
483
+ * columns the coarse and fine steps go before `enter keep` or `x reset`.
484
+ */
485
+ export const FADER_HINT =
486
+ "←→ adjust · enter keep · x reset · 0-9 type · ↑↓ param · ⇧←→ coarse · [ ] fine · esc revert";
487
+
488
+ /**
489
+ * The hint for a field the loop cannot stage (tempo, meter, loop length):
490
+ * each step is a new revision, so there is nothing to keep or revert.
491
+ */
492
+ /** The knob front page (design §8.6): the four knobs, then Tab for all. */
493
+ export const KNOB_FADER_HINT =
494
+ "↑↓ knob · ←→ turn · tab all · enter keep · ⇧ coarse · x reset · esc revert";
495
+
496
+ /** Every param behind Tab: Tab goes back to the four knobs. */
497
+ export const ALL_FADER_HINT =
498
+ "←→ adjust · ↑↓ param · tab knobs · enter keep · ⇧ coarse · x reset · esc revert";
499
+
500
+ export const FADER_HINT_AT_ONCE =
501
+ "←→ adjust · applies at once · x reset · 0-9 type · ↑↓ param · ⇧←→ coarse · [ ] fine · esc back";
502
+
503
+ /** The value a field's next step starts from (for checking what it runs). */
504
+ export function faderCommand(field: FaderSpec): string {
505
+ if (field.kind === "choice") return field.command(field.value);
506
+ return field.command(field.value ?? field.start ?? field.min);
507
+ }
339
508
 
340
509
  /** The drawer's paint model for `fields` (staged) beside `committed`. */
341
510
  export function drawerView(
@@ -345,11 +514,20 @@ export function drawerView(
345
514
  options: {
346
515
  title: string;
347
516
  dirty: boolean;
517
+ /** How many edits are staged (defaults to 1 while dirty). */
518
+ staged?: number | undefined;
348
519
  status?: string | undefined;
349
520
  hint?: string;
521
+ /** The focused field applies at once (cannot stage on the loop). */
522
+ atOnce?: boolean | undefined;
523
+ /** The drawer has a knob page: which side of Tab is up. */
524
+ knobs?: "knobs" | "all" | undefined;
350
525
  },
351
526
  ): DrawerView {
352
527
  const before = new Map(committed.map((field) => [field.label, field]));
528
+ // A detent flash lasts one view: consume it here.
529
+ const flash = state.flash;
530
+ state.flash = undefined;
353
531
  const drawn: DrawerField[] = fields.map((field) => {
354
532
  const was = before.get(field.label);
355
533
  if (field.kind === "choice") {
@@ -361,6 +539,7 @@ export function drawerView(
361
539
  return {
362
540
  kind: "choice",
363
541
  label: field.label,
542
+ knob: knobOf(field),
364
543
  options: field.options,
365
544
  index,
366
545
  committedIndex:
@@ -383,6 +562,7 @@ export function drawerView(
383
562
  return {
384
563
  kind: "number",
385
564
  label: field.label,
565
+ knob: knobOf(field),
386
566
  text,
387
567
  committed: changed ? wasText : undefined,
388
568
  position:
@@ -395,15 +575,75 @@ export function drawerView(
395
575
  : undefined,
396
576
  minText: field.format(field.min),
397
577
  maxText: field.format(field.max),
578
+ ...(flash === field.label && field.value !== undefined
579
+ ? { flash: detentLabel(field, field.value) }
580
+ : {}),
398
581
  };
399
582
  });
583
+ const staged = options.staged ?? (options.dirty ? 1 : 0);
400
584
  return {
401
585
  title: options.title,
402
586
  fields: drawn,
403
587
  focus: focusIndex(state, fields),
404
588
  typing: state.typing,
405
589
  dirty: options.dirty,
590
+ badge: options.dirty ? stagedBadge(staged) : undefined,
406
591
  status: options.status,
407
- hint: options.hint ?? HINT,
592
+ hint:
593
+ options.hint ??
594
+ (options.knobs
595
+ ? (options.knobs === "knobs" ? KNOB_FADER_HINT : ALL_FADER_HINT)
596
+ .replace(
597
+ "enter keep",
598
+ options.atOnce ? "applies at once" : "enter keep",
599
+ )
600
+ .replace("esc revert", options.atOnce ? "esc back" : "esc revert")
601
+ : options.atOnce
602
+ ? FADER_HINT_AT_ONCE
603
+ : FADER_HINT),
408
604
  };
409
605
  }
606
+
607
+ /**
608
+ * The drawer's side list: every param on the page with its value, each one
609
+ * a knob also drives wearing that knob (matched by label).
610
+ */
611
+ export function drawerAside(
612
+ page: readonly FaderSpec[],
613
+ knobs: readonly FaderSpec[],
614
+ ): DrawerAside[] {
615
+ const knobByLabel = new Map(
616
+ knobs.map((field) => [field.label, knobOf(field)] as const),
617
+ );
618
+ return page.map((field) => ({
619
+ label: field.label,
620
+ text:
621
+ field.kind === "choice"
622
+ ? field.value
623
+ : field.value === undefined
624
+ ? (field.off ?? "off")
625
+ : field.format(field.value),
626
+ knob: knobByLabel.get(field.label),
627
+ }));
628
+ }
629
+
630
+ /** A knob field's knob (0 blue … 3 orange); plain rows have none. */
631
+ function knobOf(field: FaderSpec): KnobIndex | undefined {
632
+ const knob = (field as FaderSpec & { knob?: KnobIndex }).knob;
633
+ return knob;
634
+ }
635
+
636
+ /** `A/B: 1 change staged · enter keep · esc revert`. */
637
+ export function stagedBadge(count: number): string {
638
+ const n = Math.max(1, count);
639
+ return `A/B: ${n} change${n === 1 ? "" : "s"} staged · enter keep · esc revert`;
640
+ }
641
+
642
+ /** The faint word a detent flashes: `0 dB`, `center`, or the value. */
643
+ export function detentLabel(spec: FaderNumber, value: number): string {
644
+ const text = spec.format(value);
645
+ // Volume formats as `1 · 0.0 dB`; the flash names the detent itself.
646
+ const db = /(-?[\d.]+) dB$/.exec(text);
647
+ if (db && Number(db[1]) === 0) return "0 dB";
648
+ return text;
649
+ }
@@ -135,9 +135,7 @@ export function granularMenuLabel(
135
135
  ): string | undefined {
136
136
  if (!track || track.kit || isDrumInstrument(track.instrument))
137
137
  return undefined;
138
- return isGranularInstrument(track.instrument)
139
- ? "granular"
140
- : "granular (convert)";
138
+ return isGranularInstrument(track.instrument) ? "granular" : "granular";
141
139
  }
142
140
 
143
141
  /** One-line state for the subsection's detail column. */
@@ -265,7 +263,7 @@ export function granularMenuNodes(
265
263
  return nodes;
266
264
  }
267
265
 
268
- /** Sound > browse sounds > Granular: one row per preset. */
266
+ /** Sound › instruments › granular: one row per preset. */
269
267
  export function granularBrowseNodes(): GranularMenuNode[] {
270
268
  return GRANULAR_PRESET_NAMES.map((name) => ({
271
269
  kind: "action",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The four knobs of a page as fader fields: each path of a KNOB_MAPS row
3
+ * (src/tui/knob-map.ts) resolved in the live Ctrl-K tree, so a knob turns
4
+ * exactly what that menu row turns and runs the same typed command.
5
+ */
6
+ import type { KnobIndex } from "../../tui/knobs.ts";
7
+ import type { FaderSpec } from "./fader.ts";
8
+ import { knobMap, type KnobMap } from "./knob-map.ts";
9
+ import {
10
+ faderSpecs,
11
+ rootNodes,
12
+ type MenuContext,
13
+ type MenuNode,
14
+ } from "./menu.ts";
15
+
16
+ export type KnobField = FaderSpec & { knob: KnobIndex };
17
+
18
+ const norm = (text: string) => text.trim().toLowerCase();
19
+
20
+ function matches(node: MenuNode, segment: string): boolean {
21
+ const want = norm(segment);
22
+ if (want.endsWith("*"))
23
+ return node.kind === "menu" && norm(node.id).startsWith(want.slice(0, -1));
24
+ if (node.kind === "menu" && norm(node.id) === want) return true;
25
+ return norm(node.label) === want;
26
+ }
27
+
28
+ /** The row a knob path reaches from the root, or undefined. */
29
+ export function resolveKnobPath(
30
+ context: MenuContext,
31
+ path: string,
32
+ ): MenuNode | undefined {
33
+ let nodes: readonly MenuNode[] = rootNodes(context);
34
+ let found: MenuNode | undefined;
35
+ for (const segment of path.split("/")) {
36
+ found = nodes.find((node) => matches(node, segment));
37
+ if (!found) {
38
+ // Effects beyond the first few live under "more effects".
39
+ const more = nodes.find(
40
+ (node) => node.kind === "menu" && node.id === "more effects",
41
+ );
42
+ if (more?.kind === "menu")
43
+ found = more.build(context).find((node) => matches(node, segment));
44
+ }
45
+ if (!found) return undefined;
46
+ nodes = found.kind === "menu" ? found.build(context) : [];
47
+ }
48
+ return found;
49
+ }
50
+
51
+ /**
52
+ * The knob label: the row's own label, or `reverb mix` when the row lives in
53
+ * another effect than the page's (the mixer's reverb send).
54
+ */
55
+ function knobLabel(page: string, path: string, label: string): string {
56
+ const segments = path.split("/");
57
+ if (segments[0] === "effects" && segments.length >= 3) {
58
+ const effect = segments[1]!;
59
+ if (page !== `fx:${effect}`) return `${effect} ${label}`;
60
+ }
61
+ return label;
62
+ }
63
+
64
+ /**
65
+ * The four slots of `page` for `context`: a field per slot whose path
66
+ * resolves to a number or choice row, else undefined (drawn `·`, skipped).
67
+ * A page without a row (an effect not in the table) derives one from
68
+ * `fallback`: its first three number rows, and `mix` on orange.
69
+ */
70
+ export function knobSlots(
71
+ context: MenuContext,
72
+ page: string,
73
+ fallback: readonly FaderSpec[] = [],
74
+ ): (KnobField | undefined)[] {
75
+ const map: KnobMap | undefined = knobMap(page);
76
+ if (!map) {
77
+ const numbers = fallback.filter(
78
+ (field) => field.kind === "number" && field.label !== "mix",
79
+ );
80
+ const mix = fallback.find((field) => field.label === "mix");
81
+ return [numbers[0], numbers[1], numbers[2], mix].map((field, index) =>
82
+ field ? { ...field, knob: index as KnobIndex } : undefined,
83
+ );
84
+ }
85
+ return map.map((path, index) => {
86
+ if (!path || path.startsWith("@") || !path.includes("/")) return undefined;
87
+ const node = resolveKnobPath(context, path);
88
+ const spec = node ? faderSpecs([node])[0] : undefined;
89
+ if (!spec) return undefined;
90
+ return {
91
+ ...spec,
92
+ label: knobLabel(page, path, spec.label),
93
+ knob: index as KnobIndex,
94
+ } as KnobField;
95
+ });
96
+ }
97
+
98
+ /** The present knobs of `page`, in knob order: the drawer's front page. */
99
+ export function knobFields(
100
+ context: MenuContext,
101
+ page: string,
102
+ fallback: readonly FaderSpec[] = [],
103
+ ): KnobField[] {
104
+ return knobSlots(context, page, fallback).filter(
105
+ (field): field is KnobField => field !== undefined,
106
+ );
107
+ }