@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  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/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  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 +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
package/src/tui/fader.ts CHANGED
@@ -10,6 +10,25 @@
10
10
  * all as one revision.
11
11
  */
12
12
  import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
13
+ import {
14
+ KEY_BACKSPACE,
15
+ KEY_BACKTAB,
16
+ KEY_COARSE_LEFT,
17
+ KEY_COARSE_RIGHT,
18
+ KEY_DOWN,
19
+ KEY_END,
20
+ KEY_ENTER,
21
+ KEY_FINE_LEFT,
22
+ KEY_FINE_RIGHT,
23
+ KEY_HOME,
24
+ KEY_LEFT,
25
+ KEY_PAGE_DOWN,
26
+ KEY_PAGE_UP,
27
+ KEY_RESET,
28
+ KEY_RIGHT,
29
+ KEY_TAB,
30
+ KEY_UP,
31
+ } from "../../tui/grammar.ts";
13
32
 
14
33
  export type FaderNumber = Readonly<{
15
34
  kind: "number";
@@ -22,7 +41,7 @@ export type FaderNumber = Readonly<{
22
41
  command: (value: number) => string;
23
42
  /** Typed text → command; undefined when it does not parse or fit. */
24
43
  parse: (text: string) => string | undefined;
25
- /** The command `0` / `d` runs to put the value back to its default. */
44
+ /** The command `x` / `d` / Delete runs to put the value back. */
26
45
  reset?: string | undefined;
27
46
  /** Starting value for a nudge while off. */
28
47
  start?: number | undefined;
@@ -44,6 +63,8 @@ export interface FaderState {
44
63
  label: string;
45
64
  /** Digits typed for the focused field. */
46
65
  typing?: string | undefined;
66
+ /** The field that just snapped onto a detent: flashes for one frame. */
67
+ flash?: string | undefined;
47
68
  }
48
69
 
49
70
  export type FaderResult =
@@ -111,6 +132,85 @@ function snap(spec: FaderNumber, raw: number): number {
111
132
  return Math.min(spec.max, Math.max(spec.min, best));
112
133
  }
113
134
 
135
+ /**
136
+ * Detents: values a fader catches on its way past, so the common resting
137
+ * points are easy to hit by feel. Volume 1 (0 dB), pan 0 (center), whole
138
+ * BPM, mix 0 / 0.5 / 1 and filter cutoffs on the octaves of A (27.5 Hz,
139
+ * 55 Hz … 14080 Hz). Read off the field's command, so every row that runs
140
+ * `volume`, `pan`, `tempo`, `… mix` or a cutoff gets them.
141
+ */
142
+ export type Detents = readonly number[] | "integer";
143
+
144
+ /** How close (as a share of the bar) a value must be to catch a detent. */
145
+ export const DETENT_REACH = 0.02;
146
+
147
+ const A_OCTAVES = Object.freeze(
148
+ Array.from({ length: 10 }, (_, octave) => 27.5 * 2 ** octave),
149
+ );
150
+
151
+ export function detentsFor(spec: FaderNumber): Detents | undefined {
152
+ const probe = spec.value ?? spec.start ?? spec.min;
153
+ const command = spec.command(probe).replace(/\s+\S+$/, "");
154
+ const words = command.split(/\s+/);
155
+ const verb = words[0]?.replace(/^\//, "");
156
+ if (verb === "tempo" && words.length === 1) return "integer";
157
+ if (words.includes("volume")) return [1];
158
+ if (words.includes("pan")) return [0];
159
+ if (words.at(-1) === "mix") return [0, 0.5, 1];
160
+ if (/^(cutoff|lpf|hpf)$/.test(words.at(-1) ?? "")) return A_OCTAVES;
161
+ return undefined;
162
+ }
163
+
164
+ /** The detents of `spec` that fall inside its range. */
165
+ function detentList(spec: FaderNumber, detents: Detents): number[] {
166
+ if (detents === "integer") return [];
167
+ return detents.filter((d) => d >= spec.min - 1e-9 && d <= spec.max + 1e-9);
168
+ }
169
+
170
+ /**
171
+ * `value` caught by a detent, or undefined when none is in reach. With
172
+ * `from` (a key step) a detent also catches a step that jumps over it or
173
+ * lands near it while moving toward it, so leaving a detent never sticks.
174
+ */
175
+ export function detentSnap(
176
+ spec: FaderNumber,
177
+ value: number,
178
+ from?: number,
179
+ ): number | undefined {
180
+ const detents = detentsFor(spec);
181
+ if (!detents) return undefined;
182
+ if (detents === "integer") {
183
+ const whole = Math.min(spec.max, Math.max(spec.min, Math.round(value)));
184
+ return Math.abs(whole - value) < 1e-9 ? undefined : whole;
185
+ }
186
+ const at = (v: number) => faderPosition(spec, v);
187
+ let best: number | undefined;
188
+ for (const detent of detentList(spec, detents)) {
189
+ // Landing on a detent exactly counts as a catch (it flashes) unless
190
+ // the value was already there.
191
+ if (Math.abs(detent - value) < 1e-9)
192
+ return from !== undefined && Math.abs(detent - from) > 1e-9
193
+ ? detent
194
+ : undefined;
195
+ const near = Math.abs(at(value) - at(detent)) <= DETENT_REACH;
196
+ let caught = near;
197
+ if (from !== undefined) {
198
+ const toward = Math.sign(detent - from) === Math.sign(value - from);
199
+ const crossed =
200
+ (from < detent && detent < value) || (value < detent && detent < from);
201
+ caught = Math.abs(detent - from) > 1e-9 && (crossed || (near && toward));
202
+ }
203
+ if (
204
+ caught &&
205
+ (best === undefined ||
206
+ Math.abs(at(detent) - at(from ?? value)) <
207
+ Math.abs(at(best) - at(from ?? value)))
208
+ )
209
+ best = detent;
210
+ }
211
+ return best;
212
+ }
213
+
114
214
  /** Fine (a tenth of a step), normal, coarse (five steps) or page (twenty). */
115
215
  export type StepSize = "fine" | "normal" | "coarse" | "page";
116
216
 
@@ -142,20 +242,9 @@ export function stepValue(
142
242
  return value;
143
243
  }
144
244
 
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"]);
245
+ /** ↑ and shift-tab: the previous param; ↓ and tab: the next. */
246
+ const isUp = (value: string) => KEY_UP.has(value) || KEY_BACKTAB.has(value);
247
+ const isDown = (value: string) => KEY_DOWN.has(value) || KEY_TAB.has(value);
159
248
 
160
249
  export interface FaderKeyOptions {
161
250
  /** Edits are staged (Enter keeps, Esc reverts). */
@@ -185,6 +274,32 @@ function setNumber(spec: FaderNumber, value: number): FaderResult {
185
274
  };
186
275
  }
187
276
 
277
+ /**
278
+ * Set `value`, caught by a detent unless the step is fine. `from` is the
279
+ * value a key step left (undefined for a drag or a click on the bar).
280
+ */
281
+ function setSnapped(
282
+ state: FaderState,
283
+ spec: FaderNumber,
284
+ value: number,
285
+ from: number | undefined,
286
+ size: StepSize | "drag",
287
+ ): FaderResult {
288
+ const snapped =
289
+ size === "fine"
290
+ ? undefined
291
+ : size === "drag"
292
+ ? (detentSnap(spec, value) ??
293
+ (spec.value !== undefined &&
294
+ detentSnap(spec, value, spec.value) === value
295
+ ? value
296
+ : undefined))
297
+ : detentSnap(spec, value, from);
298
+ const result = setNumber(spec, snapped ?? value);
299
+ if (snapped !== undefined && result.type === "set") state.flash = spec.label;
300
+ return result;
301
+ }
302
+
188
303
  function setChoice(spec: FaderChoice, index: number): FaderResult {
189
304
  const option =
190
305
  spec.options[Math.min(spec.options.length - 1, Math.max(0, index))];
@@ -215,12 +330,12 @@ export function faderKeyPress(
215
330
  state.typing = undefined;
216
331
  return { type: "handled" };
217
332
  }
218
- if (BACKSPACE.has(value)) {
333
+ if (KEY_BACKSPACE.has(value)) {
219
334
  state.typing = state.typing.slice(0, -1);
220
335
  if (!state.typing) state.typing = undefined;
221
336
  return { type: "handled" };
222
337
  }
223
- if (ENTER.has(value) || value === "\t") {
338
+ if (KEY_ENTER.has(value) || KEY_TAB.has(value)) {
224
339
  const text = state.typing;
225
340
  state.typing = undefined;
226
341
  const command = field.kind === "number" ? field.parse(text) : undefined;
@@ -236,9 +351,9 @@ export function faderKeyPress(
236
351
  }
237
352
  if (value === "\u001b")
238
353
  return options.dirty ? { type: "revert" } : { type: "close" };
239
- if (ENTER.has(value))
354
+ if (KEY_ENTER.has(value))
240
355
  return options.dirty ? { type: "keep" } : { type: "close" };
241
- const move = UP.has(value) ? -1 : DOWN.has(value) ? 1 : 0;
356
+ const move = isUp(value) ? -1 : isDown(value) ? 1 : 0;
242
357
  if (move) {
243
358
  state.label = fields[(index + move + fields.length) % fields.length]!.label;
244
359
  return { type: "handled" };
@@ -250,30 +365,46 @@ export function faderKeyPress(
250
365
  }
251
366
  if (field.kind === "choice") {
252
367
  const at = Math.max(0, field.options.indexOf(field.value));
253
- if (LEFT.has(value) || FINE_LEFT.has(value) || COARSE_LEFT.has(value))
368
+ if (
369
+ KEY_LEFT.has(value) ||
370
+ KEY_FINE_LEFT.has(value) ||
371
+ KEY_COARSE_LEFT.has(value)
372
+ )
254
373
  return setChoice(field, at - 1);
255
- if (RIGHT.has(value) || FINE_RIGHT.has(value) || COARSE_RIGHT.has(value))
374
+ if (
375
+ KEY_RIGHT.has(value) ||
376
+ KEY_FINE_RIGHT.has(value) ||
377
+ KEY_COARSE_RIGHT.has(value)
378
+ )
256
379
  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)
380
+ if (KEY_HOME.has(value) || KEY_PAGE_DOWN.has(value))
381
+ return setChoice(field, 0);
382
+ if (KEY_END.has(value) || KEY_PAGE_UP.has(value))
259
383
  return setChoice(field, field.options.length - 1);
260
384
  // 1…9 picks an option by number.
261
385
  if (/^[1-9]$/.test(value)) return setChoice(field, Number(value) - 1);
262
386
  return { type: "handled" };
263
387
  }
264
388
  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") {
389
+ setSnapped(
390
+ state,
391
+ field,
392
+ stepValue(field, direction, size),
393
+ field.value,
394
+ size,
395
+ );
396
+ if (KEY_LEFT.has(value)) return step(-1, "normal");
397
+ if (KEY_RIGHT.has(value)) return step(1, "normal");
398
+ if (KEY_COARSE_LEFT.has(value)) return step(-1, "coarse");
399
+ if (KEY_COARSE_RIGHT.has(value)) return step(1, "coarse");
400
+ if (KEY_FINE_LEFT.has(value)) return step(-1, "fine");
401
+ if (KEY_FINE_RIGHT.has(value)) return step(1, "fine");
402
+ if (KEY_PAGE_UP.has(value)) return step(1, "page");
403
+ if (KEY_PAGE_DOWN.has(value)) return step(-1, "page");
404
+ if (KEY_HOME.has(value)) return setNumber(field, field.min);
405
+ if (KEY_END.has(value)) return setNumber(field, field.max);
406
+ // x, d and Delete put the value back; 0-9 and . start typing.
407
+ if (KEY_RESET.has(value)) {
277
408
  const reset =
278
409
  field.reset ??
279
410
  (field.start !== undefined ? field.command(field.start) : undefined);
@@ -281,7 +412,7 @@ export function faderKeyPress(
281
412
  ? { type: "set", command: reset, key: faderKey(field.label) }
282
413
  : { type: "handled" };
283
414
  }
284
- if (/^[1-9.]$/.test(value)) {
415
+ if (/^[0-9.]$/.test(value)) {
285
416
  state.typing = value === "." ? "0." : value;
286
417
  return { type: "handled" };
287
418
  }
@@ -302,7 +433,13 @@ export function faderSetPosition(
302
433
  field,
303
434
  Math.round(position * Math.max(0, field.options.length - 1)),
304
435
  );
305
- return setNumber(field, faderValueAt(field, position));
436
+ return setSnapped(
437
+ state,
438
+ field,
439
+ faderValueAt(field, position),
440
+ undefined,
441
+ "drag",
442
+ );
306
443
  }
307
444
 
308
445
  /** A [−] / [+] click or a wheel notch on a field. */
@@ -319,7 +456,13 @@ export function faderStep(
319
456
  const at = Math.max(0, field.options.indexOf(field.value));
320
457
  return setChoice(field, at + direction);
321
458
  }
322
- return setNumber(field, stepValue(field, direction, size));
459
+ return setSnapped(
460
+ state,
461
+ field,
462
+ stepValue(field, direction, size),
463
+ field.value,
464
+ size,
465
+ );
323
466
  }
324
467
 
325
468
  /** A click on one option of a choice field. */
@@ -334,8 +477,25 @@ export function faderChoose(
334
477
  return setChoice(field, option);
335
478
  }
336
479
 
337
- const HINT =
338
- "←→ adjust · ⇧←→ coarse · [ ] fine · ↑↓ param · 0-9 type · d default · enter keep · esc revert";
480
+ /**
481
+ * Most important first: fitHint trims the middle from the right, so at 80
482
+ * columns the coarse and fine steps go before `enter keep` or `x reset`.
483
+ */
484
+ export const FADER_HINT =
485
+ "←→ adjust · enter keep · x reset · 0-9 type · ↑↓ param · ⇧←→ coarse · [ ] fine · esc revert";
486
+
487
+ /**
488
+ * The hint for a field the loop cannot stage (tempo, meter, loop length):
489
+ * each step is a new revision, so there is nothing to keep or revert.
490
+ */
491
+ export const FADER_HINT_AT_ONCE =
492
+ "←→ adjust · applies at once · x reset · 0-9 type · ↑↓ param · ⇧←→ coarse · [ ] fine · esc back";
493
+
494
+ /** The value a field's next step starts from (for checking what it runs). */
495
+ export function faderCommand(field: FaderSpec): string {
496
+ if (field.kind === "choice") return field.command(field.value);
497
+ return field.command(field.value ?? field.start ?? field.min);
498
+ }
339
499
 
340
500
  /** The drawer's paint model for `fields` (staged) beside `committed`. */
341
501
  export function drawerView(
@@ -345,11 +505,18 @@ export function drawerView(
345
505
  options: {
346
506
  title: string;
347
507
  dirty: boolean;
508
+ /** How many edits are staged (defaults to 1 while dirty). */
509
+ staged?: number | undefined;
348
510
  status?: string | undefined;
349
511
  hint?: string;
512
+ /** The focused field applies at once (cannot stage on the loop). */
513
+ atOnce?: boolean | undefined;
350
514
  },
351
515
  ): DrawerView {
352
516
  const before = new Map(committed.map((field) => [field.label, field]));
517
+ // A detent flash lasts one view: consume it here.
518
+ const flash = state.flash;
519
+ state.flash = undefined;
353
520
  const drawn: DrawerField[] = fields.map((field) => {
354
521
  const was = before.get(field.label);
355
522
  if (field.kind === "choice") {
@@ -395,15 +562,35 @@ export function drawerView(
395
562
  : undefined,
396
563
  minText: field.format(field.min),
397
564
  maxText: field.format(field.max),
565
+ ...(flash === field.label && field.value !== undefined
566
+ ? { flash: detentLabel(field, field.value) }
567
+ : {}),
398
568
  };
399
569
  });
570
+ const staged = options.staged ?? (options.dirty ? 1 : 0);
400
571
  return {
401
572
  title: options.title,
402
573
  fields: drawn,
403
574
  focus: focusIndex(state, fields),
404
575
  typing: state.typing,
405
576
  dirty: options.dirty,
577
+ badge: options.dirty ? stagedBadge(staged) : undefined,
406
578
  status: options.status,
407
- hint: options.hint ?? HINT,
579
+ hint: options.hint ?? (options.atOnce ? FADER_HINT_AT_ONCE : FADER_HINT),
408
580
  };
409
581
  }
582
+
583
+ /** `A/B: 1 change staged · enter keep · esc revert`. */
584
+ export function stagedBadge(count: number): string {
585
+ const n = Math.max(1, count);
586
+ return `A/B: ${n} change${n === 1 ? "" : "s"} staged · enter keep · esc revert`;
587
+ }
588
+
589
+ /** The faint word a detent flashes: `0 dB`, `center`, or the value. */
590
+ export function detentLabel(spec: FaderNumber, value: number): string {
591
+ const text = spec.format(value);
592
+ // Volume formats as `1 · 0.0 dB`; the flash names the detent itself.
593
+ const db = /(-?[\d.]+) dB$/.exec(text);
594
+ if (db && Number(db[1]) === 0) return "0 dB";
595
+ return text;
596
+ }
@@ -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",