@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/tui/drawer.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * habit). The piano roll stays visible above it while the terminal is tall
6
6
  * enough.
7
7
  *
8
- * ╭ fader · filter ────────────────────── ● staged [keep] [revert] ╮
8
+ * ╭ Effects › filter ─── A/B: 1 change staged · enter keep · esc revert ╮
9
9
  * │ › cutoff 1200 Hz ← 800 Hz 20 … 20000 Hz │
10
10
  * │ [−] ━━━━━━━━━━━━━━━━━━●────────────────────────────────── [+] │
11
11
  * │ type lowpass │ highpass │ bandpass │ notch │
@@ -24,6 +24,7 @@ import type { HitMap } from "./hits.ts";
24
24
  import { onBackground, type Style, type Theme } from "./theme.ts";
25
25
  import { displayWidth, truncate } from "./text.ts";
26
26
  import { asciiHint, fitHint } from "./grammar.ts";
27
+ import { knobGlyph, knobStyle, type KnobIndex } from "./knobs.ts";
27
28
 
28
29
  export type DrawerField =
29
30
  | Readonly<{
@@ -39,6 +40,12 @@ export type DrawerField =
39
40
  committedPosition?: number | undefined;
40
41
  minText: string;
41
42
  maxText: string;
43
+ /** Just caught by a detent: the bar brightens for one frame. */
44
+ flash?: string | undefined;
45
+ /** Other panes with this parameter open (`B C`), dim at the right. */
46
+ peers?: string | undefined;
47
+ /** The knob this row is (front page): its glyph and colour. */
48
+ knob?: KnobIndex | undefined;
42
49
  }>
43
50
  | Readonly<{
44
51
  kind: "choice";
@@ -46,6 +53,8 @@ export type DrawerField =
46
53
  options: readonly string[];
47
54
  index: number;
48
55
  committedIndex?: number | undefined;
56
+ peers?: string | undefined;
57
+ knob?: KnobIndex | undefined;
49
58
  }>;
50
59
 
51
60
  export type DrawerView = Readonly<{
@@ -56,11 +65,40 @@ export type DrawerView = Readonly<{
56
65
  typing?: string | undefined;
57
66
  /** Edits differ from the committed score. */
58
67
  dirty: boolean;
68
+ /** `A/B: 1 change staged · enter keep · esc revert` while dirty. */
69
+ badge?: string | undefined;
59
70
  /** `♪ solo · B` while the audition loop plays. */
60
71
  status?: string | undefined;
61
72
  hint: string;
73
+ /**
74
+ * Every param on the page behind Tab, while the drawer shows the four
75
+ * knobs: a wide drawer lists them beside the knobs (`DRAWER_MEASURE`).
76
+ */
77
+ aside?: readonly DrawerAside[] | undefined;
62
78
  }>;
63
79
 
80
+ /** One row of the drawer's side list: a param and its value. */
81
+ export type DrawerAside = Readonly<{
82
+ label: string;
83
+ text: string;
84
+ knob?: KnobIndex | undefined;
85
+ }>;
86
+
87
+ /**
88
+ * The widest a drawer row (label, value, bar, range) runs: a fader bar past
89
+ * this is no easier to read or aim. Room right of it lists the page's other
90
+ * params when the view has an aside, else stays panel.
91
+ */
92
+ export const DRAWER_MEASURE = 96;
93
+ /** The fewest columns the side list needs beside the rows. */
94
+ export const ASIDE_MIN_WIDTH = 28;
95
+
96
+ /** Columns the side list gets in a drawer `width` wide (0: none). */
97
+ export function asideWidth(width: number): number {
98
+ const room = width - 4 - DRAWER_MEASURE - 3;
99
+ return room >= ASIDE_MIN_WIDTH ? Math.min(room, 48) : 0;
100
+ }
101
+
64
102
  export type DrawerMode = "full" | "compact" | "line";
65
103
 
66
104
  export interface DrawerLayout {
@@ -85,12 +123,20 @@ export function keepAbove(regionHeight: number): number {
85
123
  * its bottom, as tall as its fields need within the budget.
86
124
  */
87
125
  export function drawerLayout(
88
- view: Pick<DrawerView, "fields" | "focus">,
126
+ view: Pick<DrawerView, "fields" | "focus"> &
127
+ Partial<Pick<DrawerView, "aside">>,
89
128
  region: { y: number; height: number },
129
+ width = 0,
90
130
  ): DrawerLayout {
91
131
  const total = Math.max(1, view.fields.length);
92
132
  const bottom = region.y + region.height;
93
133
  const budget = region.height - keepAbove(region.height);
134
+ // A side list beside the knobs may make a full drawer taller, never
135
+ // past half the region.
136
+ const aside =
137
+ view.aside && asideWidth(width) > 0
138
+ ? Math.min(view.aside.length + 1, Math.floor(region.height / 2) - 2)
139
+ : 0;
94
140
  const window = (rows: number): { first: number; count: number } => {
95
141
  const count = Math.max(1, Math.min(total, rows));
96
142
  const focus = Math.min(Math.max(0, view.focus), total - 1);
@@ -98,7 +144,7 @@ export function drawerLayout(
98
144
  return { first, count };
99
145
  };
100
146
  if (2 + 2 * total <= Math.floor(region.height / 2)) {
101
- const height = 2 + 2 * total;
147
+ const height = 2 + Math.max(2 * total, aside);
102
148
  return {
103
149
  mode: "full",
104
150
  top: bottom - height,
@@ -175,8 +221,13 @@ const ASCII: Glyphs = {
175
221
  export interface DrawerPaint {
176
222
  theme: Theme;
177
223
  unicode: boolean;
224
+ /** /motion off: detents still snap, the flash is not drawn. */
225
+ reducedMotion?: boolean;
178
226
  }
179
227
 
228
+ /** Views whose detent flash has been drawn: a flash lasts one frame. */
229
+ const flashed = new WeakSet<DrawerView>();
230
+
180
231
  /** Paint the drawer into the highway region; returns its layout. */
181
232
  export function paintDrawer(
182
233
  buffer: CellBuffer,
@@ -188,7 +239,10 @@ export function paintDrawer(
188
239
  ): DrawerLayout | undefined {
189
240
  if (region.height <= 0 || width < 16 || view.fields.length === 0)
190
241
  return undefined;
191
- const layout = drawerLayout(view, region);
242
+ const layout = drawerLayout(view, region, width);
243
+ const flashing = !options.reducedMotion && !flashed.has(view);
244
+ if (view.fields.some((field) => field.kind === "number" && field.flash))
245
+ flashed.add(view);
192
246
  const roles = options.theme.roles;
193
247
  const glyphs = options.unicode ? UNICODE : ASCII;
194
248
  const panel = roles.panel;
@@ -212,37 +266,58 @@ export function paintDrawer(
212
266
  buffer.set(width - 1, y, box.v, border);
213
267
  }
214
268
  inner = { left: 2, right: width - 2 };
215
- // Title left; staged state and the keep / revert buttons right.
216
- const keep = "[keep]";
217
- const revert = "[revert]";
218
- const buttons = view.dirty ? `${keep} ${revert}` : "";
219
- const state = view.dirty ? `${options.unicode ? "●" : "*"} staged` : "";
220
- const status = [view.status, state].filter(Boolean).join(" · ");
221
- const right = [status, buttons].filter(Boolean).join(" ");
269
+ // Title left; the status and the staged badge right. The badge's
270
+ // `enter keep` and `esc revert` are click targets; when room is short
271
+ // the breadcrumb truncates first, then the badge drops its key words.
272
+ const room = width - 6;
273
+ const full = view.badge ?? (view.dirty ? "A/B: changes staged" : "");
274
+ // The badge already counts staged changes; the audition status's
275
+ // `B staged N` would say it twice.
276
+ const status = (view.status ?? "")
277
+ .split(" · ")
278
+ .filter((part) => !(full && /^B staged \d+$/.test(part)))
279
+ .join(" · ");
280
+ const short = full.replace(/ · enter keep · esc revert$/, "");
281
+ const join = (badge: string) => [status, badge].filter(Boolean).join(" · ");
282
+ const minTitle = Math.min(displayWidth(view.title), 12) + 2;
283
+ let right = join(full);
284
+ if (displayWidth(right) + 3 + minTitle > room) right = join(short);
285
+ if (displayWidth(right) + 3 + minTitle > room) right = short;
286
+ if (displayWidth(right) + 3 + minTitle > room) right = "";
222
287
  const rightWidth = displayWidth(right);
223
- const titleRoom = Math.max(0, width - 6 - (right ? rightWidth + 3 : 0));
288
+ const titleRoom = Math.max(0, room - (right ? rightWidth + 3 : 0));
224
289
  buffer.text(
225
290
  2,
226
291
  top,
227
292
  ` ${truncate(view.title, Math.max(0, titleRoom - 2))} `,
228
293
  on({ ...roles.text, bold: true }),
229
294
  );
230
- if (right && rightWidth + 4 < width - displayWidth(view.title)) {
295
+ if (right) {
231
296
  const rx = width - 3 - rightWidth;
232
297
  buffer.text(
233
298
  rx - 1,
234
299
  top,
235
- ` ${status}${status && buttons ? " " : ""}`,
300
+ ` ${right}`,
236
301
  on(view.dirty ? roles.warning : roles.muted),
237
302
  );
238
- if (buttons) {
239
- const kx = width - 3 - displayWidth(buttons);
240
- buffer.text(kx, top, `${keep} `, on({ ...roles.success, bold: true }));
241
- buffer.text(kx + keep.length + 1, top, revert, on(roles.muted));
242
- hits?.add(kx, top, keep.length, 1, { kind: "fader-keep" });
243
- hits?.add(kx + keep.length + 1, top, revert.length, 1, {
244
- kind: "fader-revert",
245
- });
303
+ for (const [word, kind] of [
304
+ ["enter keep", "fader-keep"],
305
+ ["esc revert", "fader-revert"],
306
+ ] as const) {
307
+ const at = right.lastIndexOf(word);
308
+ if (at < 0) continue;
309
+ const x = rx + displayWidth(right.slice(0, at));
310
+ buffer.text(
311
+ x,
312
+ top,
313
+ word,
314
+ on(
315
+ kind === "fader-keep"
316
+ ? { ...roles.success, bold: true }
317
+ : roles.muted,
318
+ ),
319
+ );
320
+ hits?.add(x, top, word.length, 1, { kind });
246
321
  }
247
322
  }
248
323
  const hint = fitHint(
@@ -257,6 +332,19 @@ export function paintDrawer(
257
332
  on(roles.muted),
258
333
  );
259
334
  }
335
+ const outer = inner;
336
+ inner = {
337
+ left: inner.left,
338
+ right: Math.min(inner.right, inner.left + DRAWER_MEASURE),
339
+ };
340
+ if (layout.bordered && view.aside)
341
+ paintAside(buffer, layout, outer.right, view.aside, {
342
+ glyphs,
343
+ roles,
344
+ on,
345
+ theme: options.theme,
346
+ unicode: options.unicode,
347
+ });
260
348
  const labelWidth = Math.min(
261
349
  layout.mode === "full" ? 22 : 16,
262
350
  Math.max(...view.fields.map((field) => displayWidth(field.label))),
@@ -275,12 +363,25 @@ export function paintDrawer(
275
363
  const labelStyle = focused
276
364
  ? on({ ...roles.borderFocus, bold: true })
277
365
  : on(roles.text);
278
- buffer.text(inner.left, y, marker, labelStyle);
366
+ if (field.knob !== undefined) {
367
+ // A knob row: its shape glyph in its colour, then the marker, so
368
+ // colour is never the only cue (NO_COLOR keeps the shape).
369
+ buffer.text(
370
+ inner.left,
371
+ y,
372
+ knobGlyph(field.knob, options.unicode),
373
+ on(knobStyle(field.knob, options.theme)),
374
+ );
375
+ buffer.text(inner.left + 1, y, focused ? glyphs.marker : " ", labelStyle);
376
+ } else buffer.text(inner.left, y, marker, labelStyle);
279
377
  buffer.text(
280
378
  inner.left + 2,
281
379
  y,
282
380
  truncate(field.label, labelWidth),
283
- labelStyle,
381
+ // The selected knob's label is reverse video (§7.2).
382
+ field.knob !== undefined && focused
383
+ ? on({ ...roles.text, bold: true, reverse: true })
384
+ : labelStyle,
284
385
  );
285
386
  const valueX = inner.left + 2 + labelWidth + 2;
286
387
  const typing = focused && view.typing !== undefined;
@@ -306,15 +407,21 @@ export function paintDrawer(
306
407
  on(roles.muted),
307
408
  inner.right - valueX - used,
308
409
  );
309
- const range = `${field.minText} … ${field.maxText}`;
410
+ // For the frame a detent catches, the range gives way to its name.
411
+ const range =
412
+ flashing && field.flash !== undefined
413
+ ? field.flash
414
+ : `${field.minText} … ${field.maxText}`;
310
415
  const rangeX = inner.right - displayWidth(range);
311
416
  if (rangeX > valueX + used + displayWidth(committed) + 2)
312
417
  buffer.text(rangeX, y, range, on(roles.faint));
313
418
  paintBar(buffer, y + 1, inner.left + 2, inner.right, field, index, {
314
419
  glyphs,
315
420
  focused,
421
+ flashing,
316
422
  roles,
317
423
  on,
424
+ knob: knobFill(field, options.theme),
318
425
  ...(hits ? { hits } : {}),
319
426
  });
320
427
  } else {
@@ -335,7 +442,15 @@ export function paintDrawer(
335
442
  inner.right,
336
443
  field,
337
444
  index,
338
- { glyphs, focused, roles, on, ...(hits ? { hits } : {}) },
445
+ {
446
+ glyphs,
447
+ focused,
448
+ flashing,
449
+ roles,
450
+ on,
451
+ knob: knobFill(field, options.theme),
452
+ ...(hits ? { hits } : {}),
453
+ },
339
454
  );
340
455
  }
341
456
  } else {
@@ -357,16 +472,97 @@ export function paintDrawer(
357
472
  );
358
473
  }
359
474
  }
475
+ // Other panes with this parameter open: their letters, dim, at the
476
+ // row's right edge (design §12.7).
477
+ if (field.peers) {
478
+ const peers = ` ${field.peers}`;
479
+ buffer.text(
480
+ Math.max(valueX, inner.right - displayWidth(peers)),
481
+ y,
482
+ peers,
483
+ on(roles.muted),
484
+ );
485
+ }
360
486
  }
361
487
  return layout;
362
488
  }
363
489
 
490
+ /**
491
+ * `all params · tab` and every param on the page, one per row, beside the
492
+ * knobs: what Tab would show, without leaving the knobs.
493
+ */
494
+ function paintAside(
495
+ buffer: CellBuffer,
496
+ layout: DrawerLayout,
497
+ right: number,
498
+ aside: readonly DrawerAside[],
499
+ paint: {
500
+ glyphs: Glyphs;
501
+ roles: Theme["roles"];
502
+ on: (style: Style) => Style;
503
+ theme: Theme;
504
+ unicode: boolean;
505
+ },
506
+ ): void {
507
+ const width = asideWidth(right + 2);
508
+ if (width === 0) return;
509
+ const { roles, on } = paint;
510
+ const left = right - width;
511
+ const rows = layout.height - 2;
512
+ if (rows < 2) return;
513
+ const top = layout.top + 1;
514
+ const rule = paint.unicode ? "│" : "|";
515
+ for (let y = top; y < top + rows; y += 1)
516
+ buffer.set(left - 2, y, rule, on(roles.faint));
517
+ buffer.text(left, top, truncate("all params · tab", width), on(roles.muted));
518
+ const room = rows - 1;
519
+ const shown =
520
+ aside.length > room ? aside.slice(0, Math.max(0, room - 1)) : aside;
521
+ const labelWidth = Math.min(
522
+ Math.floor(width / 2),
523
+ Math.max(0, ...shown.map((row) => displayWidth(row.label))),
524
+ );
525
+ shown.forEach((row, index) => {
526
+ const y = top + 1 + index;
527
+ if (row.knob !== undefined)
528
+ buffer.text(
529
+ left,
530
+ y,
531
+ knobGlyph(row.knob, paint.unicode),
532
+ on(knobStyle(row.knob, paint.theme)),
533
+ );
534
+ buffer.text(left + 2, y, truncate(row.label, labelWidth), on(roles.text));
535
+ const valueX = left + 2 + labelWidth + 1;
536
+ buffer.text(
537
+ valueX,
538
+ y,
539
+ truncate(row.text, Math.max(0, right - valueX)),
540
+ on(roles.muted),
541
+ );
542
+ });
543
+ if (shown.length < aside.length)
544
+ buffer.text(
545
+ left + 2,
546
+ top + room,
547
+ truncate(`+${aside.length - shown.length} more · tab`, width - 2),
548
+ on(roles.faint),
549
+ );
550
+ }
551
+
552
+ function knobFill(field: DrawerField, theme: Theme): Style | undefined {
553
+ return field.knob === undefined ? undefined : knobStyle(field.knob, theme);
554
+ }
555
+
364
556
  interface RowPaint {
365
557
  glyphs: Glyphs;
366
558
  focused: boolean;
559
+ /** Draw this field's detent flash (motion on, first frame only). */
560
+ flashing?: boolean;
367
561
  roles: Theme["roles"];
368
562
  on: (style: Style) => Style;
369
563
  hits?: HitMap;
564
+ /** A knob row's bar fills in its knob colour. */
565
+ knob?: Style | undefined;
370
566
  }
371
567
 
372
568
  /** `[−] ━━━━━●─────── [+]` from `left` to `right`, with its hit regions. */
@@ -412,7 +608,17 @@ function paintBar(
412
608
  field.committedPosition === undefined
413
609
  ? undefined
414
610
  : at(field.committedPosition);
415
- const filled = on(paint.focused ? roles.borderFocus : roles.text);
611
+ // A detent catch brightens the bar for one frame (motion on).
612
+ const flash = paint.flashing === true && field.flash !== undefined;
613
+ const filled = on(
614
+ flash
615
+ ? { ...roles.success, bold: true }
616
+ : paint.knob
617
+ ? paint.knob
618
+ : paint.focused
619
+ ? roles.borderFocus
620
+ : roles.text,
621
+ );
416
622
  const empty = on(roles.faint);
417
623
  for (let column = 0; column < barWidth; column += 1) {
418
624
  const x = barLeft + column;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * When the interactive loop builds a frame. The 30 fps timer used to build
3
+ * the whole view (score snapshot, layout, every panel) on every tick even
4
+ * with nothing moving, only for the differential writer to find no change.
5
+ * The gate builds a frame when something can have changed: a forced frame
6
+ * (input, resize, an edit landing), a change the caller reports (activity,
7
+ * a different score or status key), or a running animation (transport,
8
+ * play mode, an audition, a spinner, a card's glow). Otherwise it builds
9
+ * one heartbeat frame every `heartbeatMs`, which catches anything slow that
10
+ * nothing reported (a loudness reading, a card ageing out).
11
+ */
12
+ export const IDLE_HEARTBEAT_MS = 500;
13
+ /**
14
+ * The shortest gap between two built frames in the interactive loop: a
15
+ * burst of forced frames (keystrokes, edits landing, agent events) is
16
+ * coalesced to at most about 30 a second. A deferred frame stays dirty, so
17
+ * the next 33 ms timer tick builds it; nothing is dropped.
18
+ */
19
+ export const MIN_FRAME_GAP_MS = 33;
20
+
21
+ export class FrameGate {
22
+ private dirty = true;
23
+ private lastBuiltMs = Number.NEGATIVE_INFINITY;
24
+ private lastKeys: readonly unknown[] = [];
25
+ /** Frames built and ticks skipped, for tests and diagnostics. */
26
+ built = 0;
27
+ skipped = 0;
28
+
29
+ constructor(
30
+ readonly heartbeatMs = IDLE_HEARTBEAT_MS,
31
+ readonly minGapMs = 0,
32
+ ) {}
33
+
34
+ /** Something visible changed; the next tick builds. */
35
+ markDirty(): void {
36
+ this.dirty = true;
37
+ }
38
+
39
+ /**
40
+ * True when this tick should build a frame; counts it as built. Each of
41
+ * `keys` is compared by identity (a score object) or value (a status
42
+ * string) with the last built frame's: any difference builds.
43
+ */
44
+ shouldBuild(options: {
45
+ nowMs: number;
46
+ force?: boolean;
47
+ animating?: boolean;
48
+ keys?: readonly unknown[];
49
+ }): boolean {
50
+ const keys = options.keys ?? [];
51
+ const keyChanged =
52
+ keys.length !== this.lastKeys.length ||
53
+ keys.some((key, index) => !Object.is(key, this.lastKeys[index]));
54
+ const build =
55
+ options.force === true ||
56
+ options.animating === true ||
57
+ this.dirty ||
58
+ keyChanged ||
59
+ options.nowMs - this.lastBuiltMs >= this.heartbeatMs;
60
+ if (!build) {
61
+ this.skipped += 1;
62
+ return false;
63
+ }
64
+ if (options.nowMs - this.lastBuiltMs < this.minGapMs) {
65
+ // Too soon after the last frame: build on the next tick instead.
66
+ this.dirty = true;
67
+ this.skipped += 1;
68
+ return false;
69
+ }
70
+ this.dirty = false;
71
+ this.lastKeys = keys;
72
+ this.lastBuiltMs = options.nowMs;
73
+ this.built += 1;
74
+ return true;
75
+ }
76
+ }