@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/input.ts CHANGED
@@ -7,16 +7,42 @@
7
7
  export type TerminalInputEvent =
8
8
  string | Readonly<{ type: "paste"; text: string }>;
9
9
 
10
+ /** A bracketed paste longer than this is emitted as it stands. */
11
+ export const MAX_PASTE_CHARS = 1 << 20;
12
+
13
+ /** A chunk marker the read loop yields when input went idle mid-sequence. */
14
+ export const INPUT_FLUSH = "\u0000flush";
15
+
16
+ /** Idle time after which a partial escape prefix is a key of its own. */
17
+ export const ESCAPE_FLUSH_MS = 50;
18
+ /** Idle time after which an unterminated bracketed paste is emitted. */
19
+ export const PASTE_FLUSH_MS = 1_000;
20
+
10
21
  export class TerminalInputDecoder {
11
22
  private buffer = "";
12
23
 
24
+ /**
25
+ * What the decoder holds back: `escape` for a partial escape sequence,
26
+ * `paste` for an unterminated bracketed paste. The caller flushes after
27
+ * `ESCAPE_FLUSH_MS` or `PASTE_FLUSH_MS` with no further input.
28
+ */
29
+ pending(): "escape" | "paste" | undefined {
30
+ if (this.buffer.length === 0) return undefined;
31
+ return this.buffer.startsWith("\u001b[200~") ? "paste" : "escape";
32
+ }
33
+
13
34
  push(chunk: string): TerminalInputEvent[] {
14
35
  this.buffer += chunk;
15
36
  const events: TerminalInputEvent[] = [];
16
37
  while (this.buffer.length > 0) {
17
38
  if (this.buffer.startsWith("\u001b[200~")) {
18
39
  const end = this.buffer.indexOf("\u001b[201~", 6);
19
- if (end < 0) break;
40
+ if (end < 0) {
41
+ if (this.buffer.length - 6 < MAX_PASTE_CHARS) break;
42
+ events.push({ type: "paste", text: this.buffer.slice(6) });
43
+ this.buffer = "";
44
+ break;
45
+ }
20
46
  events.push({ type: "paste", text: this.buffer.slice(6, end) });
21
47
  this.buffer = this.buffer.slice(end + 6);
22
48
  continue;
@@ -38,12 +64,27 @@ export class TerminalInputDecoder {
38
64
  return events;
39
65
  }
40
66
 
67
+ /**
68
+ * Emits whatever is held back, once no more input is coming: a cut-off
69
+ * bracketed paste as a paste, a lone ESC as Esc, and a partial prefix such
70
+ * as `ESC [` (Alt+[) or `ESC O` (Alt+O) as one Alt key, then decodes the
71
+ * rest normally.
72
+ */
41
73
  flush(): TerminalInputEvent[] {
42
- if (this.buffer === "\u001b") {
74
+ const events: TerminalInputEvent[] = [];
75
+ while (this.buffer.length > 0) {
76
+ if (this.buffer.startsWith("\u001b[200~")) {
77
+ events.push({ type: "paste", text: this.buffer.slice(6) });
78
+ this.buffer = "";
79
+ break;
80
+ }
81
+ const head =
82
+ this.buffer.length === 1 ? "\u001b" : this.buffer.slice(0, 2);
83
+ const rest = this.buffer.slice(head.length);
43
84
  this.buffer = "";
44
- return ["\u001b"];
85
+ events.push(head, ...this.push(rest));
45
86
  }
46
- return [];
87
+ return events;
47
88
  }
48
89
 
49
90
  private readEscapeSequence(): string | undefined {
@@ -59,12 +100,22 @@ export class TerminalInputDecoder {
59
100
  return this.buffer.length >= 6 ? this.buffer.slice(0, 6) : undefined;
60
101
  const final = this.buffer.match(/^\u001b\[[\x20-?]*[\x40-~]/)?.[0];
61
102
  if (final !== undefined) return final;
62
- // SS3: arrows in application mode and F1-F4 (`ESC O P` is F1).
63
- const ss3 = this.buffer.match(/^\u001bO[A-DHFPQRS]/)?.[0];
64
- if (ss3 !== undefined) return ss3;
103
+ // A byte that cannot continue a CSI ends it: the `ESC [` was Alt+[.
104
+ if (this.buffer.startsWith("\u001b[")) {
105
+ const partial = this.buffer.match(/^\u001b\[[\x20-?]*/)![0];
106
+ if (partial.length < this.buffer.length) return "\u001b[";
107
+ }
108
+ // SS3: arrows in application mode and F1-F4 (`ESC O P` is F1). A bare
109
+ // `ESC O` waits for its final byte, which a PTY may deliver separately;
110
+ // `flush` turns it into Alt+O if nothing follows.
111
+ if (this.buffer.startsWith("\u001bO")) {
112
+ if (this.buffer.length === 2) return undefined;
113
+ const ss3 = this.buffer.match(/^\u001bO[A-DHFPQRS]/)?.[0];
114
+ if (ss3 !== undefined) return ss3;
115
+ return "\u001bO";
116
+ }
65
117
  // An unrecognised Alt-prefixed character is still one complete event.
66
- if (this.buffer.length >= 2 && this.buffer[1] !== "[")
67
- return this.buffer.slice(0, 2);
118
+ if (this.buffer[1] !== "[") return this.buffer.slice(0, 2);
68
119
  return undefined;
69
120
  }
70
121
  }
package/tui/keys.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The prompt is always focused, so printable keys always type. Control
5
5
  * chords carry the global actions:
6
- * Ctrl+C quit · Ctrl+Q toggle STEER/QUEUE · Ctrl+Z undo · Ctrl+Y redo
6
+ * Ctrl+C quit · Ctrl+Q toggle NOW/NEXT · Ctrl+Z undo · Ctrl+Y redo
7
7
  * Ctrl+O transcript overlay · Esc closes the overlay
8
8
  */
9
9
 
package/tui/knobs.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The four knobs (design §7, the OP-1's four colour encoders adapted): one
3
+ * table names each knob's colour, shape glyph and meaning, and every screen
4
+ * that shows knobs (the fader drawer's front page, the mixer page, euclid,
5
+ * and later TAPE and PLAY) reads it, so a knob looks and means the same
6
+ * everywhere.
7
+ *
8
+ * ● blue moves position, selection, which
9
+ * ▲ green sizes length, range, amount of steps
10
+ * ■ white shapes tone, time, character
11
+ * ◆ orange level loudness, mix, velocity
12
+ *
13
+ * Colour is never the only cue: each knob has a shape glyph, which is all
14
+ * that is left under NO_COLOR and the mono theme.
15
+ *
16
+ * Keys, the same on every screen: ↑↓ picks a knob (skipping empty slots),
17
+ * ←→ turns it, ⇧←→ turns it coarse, x resets it, enter opens it big.
18
+ * Every turn is a typed command, so the receipt teaches the words.
19
+ *
20
+ * Pure: the reducer returns what to run; the caller runs it.
21
+ */
22
+ import type { CellBuffer } from "./screen.ts";
23
+ import { displayWidth, truncate } from "./text.ts";
24
+ import type { SemanticRole, Style, Theme } from "./theme.ts";
25
+ import {
26
+ KEY_COARSE_LEFT,
27
+ KEY_COARSE_RIGHT,
28
+ KEY_DOWN,
29
+ KEY_ENTER,
30
+ KEY_LEFT,
31
+ KEY_RESET,
32
+ KEY_RIGHT,
33
+ KEY_UP,
34
+ } from "./grammar.ts";
35
+
36
+ export type KnobIndex = 0 | 1 | 2 | 3;
37
+
38
+ export type Knob = Readonly<{
39
+ index: KnobIndex;
40
+ colour: "blue" | "green" | "white" | "orange";
41
+ /** What this knob means on every screen. */
42
+ meaning: "moves" | "sizes" | "shapes" | "level";
43
+ role: Extract<SemanticRole, "knob1" | "knob2" | "knob3" | "knob4">;
44
+ /** Shape glyph (width 1), so colour is never the only cue. */
45
+ glyph: string;
46
+ /** The glyph where the terminal has no Unicode. */
47
+ ascii: string;
48
+ }>;
49
+
50
+ /** The one table: glyphs, colours and meanings of the four knobs. */
51
+ export const KNOBS: readonly [Knob, Knob, Knob, Knob] = Object.freeze([
52
+ {
53
+ index: 0,
54
+ colour: "blue",
55
+ meaning: "moves",
56
+ role: "knob1",
57
+ glyph: "●",
58
+ ascii: "o",
59
+ },
60
+ {
61
+ index: 1,
62
+ colour: "green",
63
+ meaning: "sizes",
64
+ role: "knob2",
65
+ glyph: "▲",
66
+ ascii: "^",
67
+ },
68
+ {
69
+ index: 2,
70
+ colour: "white",
71
+ meaning: "shapes",
72
+ role: "knob3",
73
+ glyph: "■",
74
+ ascii: "#",
75
+ },
76
+ {
77
+ index: 3,
78
+ colour: "orange",
79
+ meaning: "level",
80
+ role: "knob4",
81
+ glyph: "◆",
82
+ ascii: "*",
83
+ },
84
+ ] as const);
85
+
86
+ export function knobGlyph(index: KnobIndex, unicode: boolean): string {
87
+ const knob = KNOBS[index];
88
+ return unicode ? knob.glyph : knob.ascii;
89
+ }
90
+
91
+ export function knobStyle(index: KnobIndex, theme: Theme): Style {
92
+ return theme.roles[KNOBS[index].role];
93
+ }
94
+
95
+ /** The footer hint of a knob page, the same words on every screen. */
96
+ export const KNOB_HINT =
97
+ "↑↓ knob · ←→ turn · ⇧ coarse · x reset · tab more · esc close";
98
+
99
+ /** One knob slot as a screen shows and turns it. */
100
+ export type KnobSlot = Readonly<{
101
+ label: string;
102
+ /** Formatted value: `1200 Hz`, `saw`, `−4.9 dB`. */
103
+ text: string;
104
+ /** 0…1 along the gauge; undefined while off or for a choice. */
105
+ position?: number | undefined;
106
+ /** The command one turn runs, or undefined at the end of the range. */
107
+ turn: (direction: 1 | -1, coarse: boolean) => string | undefined;
108
+ /** The command `x` runs, when the slot has a default. */
109
+ reset?: string | undefined;
110
+ }>;
111
+
112
+ /** Four slots; an undefined one draws as `·` and ↑↓ skips it. */
113
+ export type KnobSlots = readonly [
114
+ KnobSlot | undefined,
115
+ KnobSlot | undefined,
116
+ KnobSlot | undefined,
117
+ KnobSlot | undefined,
118
+ ];
119
+
120
+ export type KnobState = { selected: KnobIndex };
121
+
122
+ export type KnobResult =
123
+ | { type: "handled" }
124
+ | { type: "pass" }
125
+ | { type: "run"; command: string }
126
+ | { type: "open"; knob: KnobIndex };
127
+
128
+ /**
129
+ * The next knob from `from` in `direction`, wrapping blue → green → white →
130
+ * orange and skipping empty slots; `from` itself when every slot is empty.
131
+ */
132
+ export function nextKnob(
133
+ from: KnobIndex,
134
+ direction: 1 | -1,
135
+ present: (index: KnobIndex) => boolean,
136
+ ): KnobIndex {
137
+ for (let step = 1; step <= 4; step += 1) {
138
+ const index = (((from + direction * step) % 4) + 4) % 4;
139
+ if (present(index as KnobIndex)) return index as KnobIndex;
140
+ }
141
+ return from;
142
+ }
143
+
144
+ /** The first non-empty slot, for opening on a page. */
145
+ export function firstKnob(slots: KnobSlots): KnobIndex {
146
+ const at = slots.findIndex(Boolean);
147
+ return (at < 0 ? 0 : at) as KnobIndex;
148
+ }
149
+
150
+ /** One key on a knob strip. */
151
+ export function knobKey(
152
+ state: KnobState,
153
+ slots: KnobSlots,
154
+ value: string,
155
+ ): KnobResult {
156
+ if (!slots[state.selected]) state.selected = firstKnob(slots);
157
+ const up = KEY_UP.has(value);
158
+ if (up || KEY_DOWN.has(value)) {
159
+ state.selected = nextKnob(state.selected, up ? -1 : 1, (index) =>
160
+ Boolean(slots[index]),
161
+ );
162
+ return { type: "handled" };
163
+ }
164
+ const slot = slots[state.selected];
165
+ if (!slot) return { type: "pass" };
166
+ const coarseLeft = KEY_COARSE_LEFT.has(value);
167
+ const coarseRight = KEY_COARSE_RIGHT.has(value);
168
+ if (
169
+ coarseLeft ||
170
+ coarseRight ||
171
+ KEY_LEFT.has(value) ||
172
+ KEY_RIGHT.has(value)
173
+ ) {
174
+ const direction = coarseRight || KEY_RIGHT.has(value) ? 1 : -1;
175
+ const command = slot.turn(direction, coarseLeft || coarseRight);
176
+ return command ? { type: "run", command } : { type: "handled" };
177
+ }
178
+ if (KEY_RESET.has(value))
179
+ return slot.reset
180
+ ? { type: "run", command: slot.reset }
181
+ : { type: "handled" };
182
+ if (KEY_ENTER.has(value)) return { type: "open", knob: state.selected };
183
+ return { type: "pass" };
184
+ }
185
+
186
+ /**
187
+ * `●›cutoff 1.2k ▲ q 0.7 ■ type lowpass ◆ mix 0.3`: the strip as plain
188
+ * text, for pickers and receipts. The selected knob carries `›` (`>`).
189
+ */
190
+ export function knobStripText(
191
+ slots: KnobSlots,
192
+ selected: KnobIndex,
193
+ unicode: boolean,
194
+ ): string {
195
+ return slots
196
+ .map((slot, index) => {
197
+ const knob = index as KnobIndex;
198
+ const mark = knob === selected ? (unicode ? "›" : ">") : " ";
199
+ const glyph = knobGlyph(knob, unicode);
200
+ return slot ? `${glyph}${mark}${slot.label} ${slot.text}` : `${glyph} ·`;
201
+ })
202
+ .join(" ");
203
+ }
204
+
205
+ /**
206
+ * Paint a one-row knob strip at (x, y) within `width`: each slot is its
207
+ * glyph in its knob colour, the label, the value and a short gauge. The
208
+ * selected slot's label is reverse video. Returns the cells used.
209
+ */
210
+ /** The widest one knob-strip slot gets (label, value and gauge). */
211
+ export const KNOB_CELL_MAX = 40;
212
+
213
+ export function paintKnobStrip(
214
+ buffer: CellBuffer,
215
+ x: number,
216
+ y: number,
217
+ width: number,
218
+ slots: KnobSlots,
219
+ selected: KnobIndex,
220
+ options: { theme: Theme; unicode: boolean; background?: Style },
221
+ ): number {
222
+ // Past KNOB_CELL_MAX a slot's gauge only stretches; the strip stays
223
+ // left-aligned and the rest of the row is free.
224
+ const cell = Math.min(KNOB_CELL_MAX, Math.floor(width / 4));
225
+ if (cell < 6) return 0;
226
+ const background = options.background ?? {};
227
+ const on = (style: Style): Style => ({ ...background, ...style });
228
+ slots.forEach((slot, index) => {
229
+ const knob = index as KnobIndex;
230
+ const left = x + cell * index;
231
+ const style = knobStyle(knob, options.theme);
232
+ buffer.text(left, y, knobGlyph(knob, options.unicode), on(style));
233
+ if (!slot) {
234
+ buffer.text(left + 2, y, "·", on(options.theme.roles.faint));
235
+ return;
236
+ }
237
+ const label = truncate(`${slot.label} ${slot.text}`, cell - 3);
238
+ buffer.text(
239
+ left + 2,
240
+ y,
241
+ label,
242
+ on(
243
+ knob === selected
244
+ ? { ...options.theme.roles.text, reverse: true }
245
+ : options.theme.roles.text,
246
+ ),
247
+ );
248
+ const gauge = cell - 3 - displayWidth(label) - 1;
249
+ if (slot.position !== undefined && gauge >= 3) {
250
+ const filled = Math.round(
251
+ Math.min(1, Math.max(0, slot.position)) * gauge,
252
+ );
253
+ buffer.text(
254
+ left + 3 + displayWidth(label),
255
+ y,
256
+ (options.unicode ? "█" : "=").repeat(filled),
257
+ on(style),
258
+ );
259
+ buffer.text(
260
+ left + 3 + displayWidth(label) + filled,
261
+ y,
262
+ (options.unicode ? "─" : "-").repeat(gauge - filled),
263
+ on(options.theme.roles.faint),
264
+ );
265
+ }
266
+ });
267
+ return cell * 4;
268
+ }
package/tui/play-strip.ts CHANGED
@@ -1,21 +1,31 @@
1
1
  /**
2
- * Play mode's two fixed rows: the header (`PLAY C3–F4 vel 100 ● REC
2
+ * Play mode's two fixed rows: the header (`PLAY MODE ▶ 120 BPM · lead ·
3
+ * rev 3 C3–F4 vel 100 ● REC
3
4
  * click ✓` plus a beat flash) and a one-line keyboard strip with the keys
4
5
  * that are sounding lit. Both repaint in place every frame, so playing never
5
6
  * scrolls the transcript.
6
7
  */
7
8
  import type { CellBuffer } from "./screen.ts";
8
9
  import { displayWidth, truncate } from "./text.ts";
9
- import { onBackground, type Theme } from "./theme.ts";
10
+ import { onBackground, type Style, type Theme } from "./theme.ts";
10
11
 
11
12
  export type PlayStripKey = Readonly<{
12
13
  key: string;
13
14
  label: string;
14
15
  black: boolean;
15
16
  lit: boolean;
17
+ /** Chord mode: the label names the chord this key plays. */
18
+ chord?: boolean | undefined;
19
+ /** That chord is built on a degree of the song's key. */
20
+ diatonic?: boolean | undefined;
16
21
  }>;
17
22
 
18
23
  export type PlayHeaderView = Readonly<{
24
+ /**
25
+ * The song header compressed to the left of the row while play mode
26
+ * covers it: `▶ 120 BPM · drums · rev 1`.
27
+ */
28
+ context?: string | undefined;
19
29
  /** `C3–F4`. */
20
30
  range: string;
21
31
  velocity: number;
@@ -24,6 +34,8 @@ export type PlayHeaderView = Readonly<{
24
34
  /** Recording right now (armed and the transport is running). */
25
35
  recording: boolean;
26
36
  replace: boolean;
37
+ /** Loop recording: `↻ 5–6 · pass 3`. */
38
+ pass?: string | undefined;
27
39
  click: boolean;
28
40
  sustain: boolean;
29
41
  /** `count-in 3` while counting in. */
@@ -51,11 +63,17 @@ export type ChordLegendCell = Readonly<{
51
63
  /** Text of the header row, for tests and narrow terminals. */
52
64
  export function playHeaderText(view: PlayHeaderView, unicode = true): string {
53
65
  const parts = [
54
- "PLAY",
66
+ "PLAY MODE",
67
+ view.context ?? "",
55
68
  view.range,
56
69
  view.armed
57
70
  ? `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`
58
71
  : "",
72
+ view.pass
73
+ ? unicode
74
+ ? view.pass
75
+ : view.pass.replace("↻", "@").replace("–", "-")
76
+ : "",
59
77
  view.click ? "click" : "",
60
78
  view.sustain ? "SUSTAIN" : "",
61
79
  view.chords ?? "",
@@ -87,19 +105,29 @@ export function paintPlayHeader(
87
105
  );
88
106
  x += buffer.text(x, y, " ", panel);
89
107
  };
90
- put(" PLAY ", roles.pillSteer);
91
- put(view.range, { ...roles.text, bold: true });
108
+ put(" PLAY MODE ", roles.pillSteer);
109
+ const context = fitContext(view, width, unicode);
110
+ if (context) put(context, roles.muted);
111
+ // A kit names its range by the track already in the context.
112
+ if (!context.includes(" · ") || view.range !== "drums")
113
+ put(view.range, { ...roles.text, bold: true });
92
114
  if (view.armed)
93
115
  put(
94
116
  `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`,
95
117
  view.recording ? roles.error : roles.warning,
96
118
  );
119
+ if (view.pass)
120
+ put(
121
+ unicode ? view.pass : view.pass.replace("↻", "@").replace("–", "-"),
122
+ roles.knob2,
123
+ );
97
124
  // Velocity, grid and click details live in the `?` panel.
98
125
  if (view.click) put("click", roles.success);
99
126
  if (view.sustain) put("SUSTAIN", roles.pillQueue);
100
127
  if (view.chords) put(view.chords, roles.hit);
101
128
  if (view.countIn) put(view.countIn, roles.warning);
102
- if (view.beat) {
129
+ const hintWidth = displayWidth("? keys · esc leave");
130
+ if (view.beat && x + view.beat.of + 2 <= width - 1 - hintWidth) {
103
131
  const cells = Array.from({ length: view.beat.of }, (_, index) =>
104
132
  index + 1 === view.beat!.index
105
133
  ? unicode
@@ -136,6 +164,31 @@ export function paintPlayHeader(
136
164
  if (right > x) buffer.text(right, y, hint, onBackground(roles.faint, panel));
137
165
  }
138
166
 
167
+ /**
168
+ * The compressed song header (`▶ 120 BPM · bass · rev 1`). It is reserved
169
+ * first: the beat dots and the status give way before it does, and only a
170
+ * terminal too narrow for it with the badge and the way out shortens it to
171
+ * its first clause.
172
+ */
173
+ function fitContext(
174
+ view: PlayHeaderView,
175
+ width: number,
176
+ unicode: boolean,
177
+ ): string {
178
+ if (!view.context) return "";
179
+ const rest = displayWidth(
180
+ playHeaderText(
181
+ { ...view, context: undefined, status: undefined, beat: undefined },
182
+ unicode,
183
+ ),
184
+ );
185
+ // Badge padding, the right-hand hint and the gaps between parts.
186
+ const room = width - 2 - displayWidth("? keys · esc leave") - 4 - rest;
187
+ for (const candidate of [view.context, view.context.split(" · ")[0]!])
188
+ if (displayWidth(candidate) + 2 <= room) return candidate;
189
+ return "";
190
+ }
191
+
139
192
  /** Chord mode's number row: `1 dim 2 min … 9 block b bass off n next`. */
140
193
  export function paintChordLegend(
141
194
  buffer: CellBuffer,
@@ -162,7 +215,11 @@ export function paintChordLegend(
162
215
  }
163
216
  }
164
217
 
165
- /** `a C3 │ w C#3 …`: each key with its note; lit keys reversed. */
218
+ /**
219
+ * `A C3 W C#3 …`: each key with what it plays. The key letter is muted so
220
+ * the music reads first; a chord label is bold, and chords in the song's
221
+ * key are lit (they always fit). Sounding keys are reversed.
222
+ */
166
223
  export function paintPlayStrip(
167
224
  buffer: CellBuffer,
168
225
  y: number,
@@ -174,15 +231,24 @@ export function paintPlayStrip(
174
231
  buffer.fill(0, y, width, 1, roles.canvas);
175
232
  let x = 1;
176
233
  for (const key of keys) {
177
- const cell = `${key.key.toUpperCase()} ${key.label}`;
178
- const cellWidth = displayWidth(cell) + 1;
234
+ const letter = key.key.toUpperCase();
235
+ const cellWidth = displayWidth(letter) + 1 + displayWidth(key.label) + 1;
179
236
  if (x + cellWidth > width - 1) break;
180
- const style = key.lit
237
+ const labelStyle: Style = key.lit
181
238
  ? { ...(key.black ? roles.selected : roles.hit), reverse: true }
239
+ : key.chord
240
+ ? { ...(key.diatonic ? roles.hit : roles.text), bold: true }
241
+ : key.black
242
+ ? roles.muted
243
+ : roles.text;
244
+ const letterStyle: Style = key.lit
245
+ ? labelStyle
182
246
  : key.black
183
- ? roles.muted
184
- : roles.text;
185
- buffer.text(x, y, cell, style);
186
- x += cellWidth;
247
+ ? roles.faint
248
+ : roles.muted;
249
+ x += buffer.text(x, y, letter, letterStyle);
250
+ x += buffer.text(x, y, " ", key.lit ? labelStyle : roles.canvas);
251
+ x += buffer.text(x, y, key.label, labelStyle);
252
+ x += 1;
187
253
  }
188
254
  }
package/tui/prompt.ts CHANGED
@@ -316,7 +316,7 @@ export function applyPromptKey(
316
316
  }
317
317
  switch (keyInput.toUpperCase()) {
318
318
  case "ENTER":
319
- // The mode pill decides what Enter does: STEER runs now, QUEUE waits.
319
+ // The mode pill decides what Enter does: NOW runs now, NEXT waits.
320
320
  return submit(state, state.mode === "queue" ? "queue" : "submit");
321
321
  case "ALT+ENTER":
322
322
  return submit(state, "queue");