@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/screen.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  */
9
9
 
10
10
  import { graphemes, graphemeWidth } from "./text.ts";
11
- import { styleSgr, type Style, type TerminalCapabilities } from "./theme.ts";
11
+ import {
12
+ foregroundSgr,
13
+ styleSgr,
14
+ type Style,
15
+ type TerminalCapabilities,
16
+ } from "./theme.ts";
12
17
 
13
18
  export interface Cell {
14
19
  /** Grapheme text; "" marks the trailing half of a wide character. */
@@ -17,6 +22,8 @@ export interface Cell {
17
22
  }
18
23
 
19
24
  const EMPTY: Style = {};
25
+ /** Unchanged cells that split a row into two writes (a CUP is ~8 bytes). */
26
+ const SPAN_GAP = 6;
20
27
 
21
28
  export class CellBuffer {
22
29
  readonly width: number;
@@ -26,10 +33,10 @@ export class CellBuffer {
26
33
  constructor(width: number, height: number, fill: Style = EMPTY) {
27
34
  this.width = Math.max(0, Math.floor(width));
28
35
  this.height = Math.max(0, Math.floor(height));
29
- this.cells = Array.from({ length: this.width * this.height }, () => ({
30
- ch: " ",
31
- style: fill,
32
- }));
36
+ const count = this.width * this.height;
37
+ this.cells = new Array<Cell>(count);
38
+ for (let index = 0; index < count; index += 1)
39
+ this.cells[index] = { ch: " ", style: fill };
33
40
  }
34
41
 
35
42
  get(x: number, y: number): Cell | undefined {
@@ -107,6 +114,77 @@ export class CellBuffer {
107
114
  return rows;
108
115
  }
109
116
 
117
+ /**
118
+ * The columns `[from, to)` of row `y` that differ from `other`, widened so
119
+ * neither edge splits a wide character, or undefined when the row matches.
120
+ */
121
+ rowSpan(
122
+ other: CellBuffer,
123
+ y: number,
124
+ ): Readonly<{ from: number; to: number }> | undefined {
125
+ if (other.width !== this.width) return { from: 0, to: this.width };
126
+ const offset = y * this.width;
127
+ const differs = (x: number) => {
128
+ const left = this.cells[offset + x]!;
129
+ const right = other.cells[offset + x]!;
130
+ if (left.ch !== right.ch) return true;
131
+ // Two blanks look the same whatever their foreground.
132
+ if (isBlank(left) && isBlank(right)) return false;
133
+ return !sameStyle(left.style, right.style);
134
+ };
135
+ let from = 0;
136
+ while (from < this.width && !differs(from)) from += 1;
137
+ if (from === this.width) return undefined;
138
+ let to = this.width;
139
+ while (to > from && !differs(to - 1)) to -= 1;
140
+ // A continuation cell ("") belongs to the wide character on its left.
141
+ while (from > 0 && this.cells[offset + from]!.ch === "") from -= 1;
142
+ while (to < this.width && this.cells[offset + to]!.ch === "") to += 1;
143
+ return { from, to };
144
+ }
145
+
146
+ /**
147
+ * The changed runs of row `y`, split wherever at least `gap` unchanged
148
+ * cells separate them (a cursor move is cheaper than repainting those),
149
+ * each widened so no edge splits a wide character.
150
+ */
151
+ rowSpans(
152
+ other: CellBuffer,
153
+ y: number,
154
+ gap = SPAN_GAP,
155
+ ): Array<{ from: number; to: number }> {
156
+ const whole = this.rowSpan(other, y);
157
+ if (!whole) return [];
158
+ if (other.width !== this.width) return [whole];
159
+ const offset = y * this.width;
160
+ const spans: Array<{ from: number; to: number }> = [];
161
+ let start = -1;
162
+ let lastDiff = -1;
163
+ for (let x = whole.from; x < whole.to; x += 1) {
164
+ const left = this.cells[offset + x]!;
165
+ const right = other.cells[offset + x]!;
166
+ const changed =
167
+ left.ch !== right.ch ||
168
+ (!(isBlank(left) && isBlank(right)) &&
169
+ !sameStyle(left.style, right.style));
170
+ if (!changed) continue;
171
+ if (start < 0) start = x;
172
+ else if (x - lastDiff - 1 >= gap) {
173
+ spans.push({ from: start, to: lastDiff + 1 });
174
+ start = x;
175
+ }
176
+ lastDiff = x;
177
+ }
178
+ if (start >= 0) spans.push({ from: start, to: lastDiff + 1 });
179
+ for (const span of spans) {
180
+ while (span.from > 0 && this.cells[offset + span.from]!.ch === "")
181
+ span.from -= 1;
182
+ while (span.to < this.width && this.cells[offset + span.to]!.ch === "")
183
+ span.to += 1;
184
+ }
185
+ return spans;
186
+ }
187
+
110
188
  rowEquals(other: CellBuffer, y: number): boolean {
111
189
  if (other.width !== this.width) return false;
112
190
  const offset = y * this.width;
@@ -122,6 +200,9 @@ export class CellBuffer {
122
200
  private clearWide(x: number, y: number): void {
123
201
  const offset = y * this.width;
124
202
  const cell = this.cells[offset + x]!;
203
+ // Fast path: one UTF-16 unit below U+1100 is never wide (nor a wide
204
+ // character's empty trail), and it is nearly every cell of a frame.
205
+ if (cell.ch.length === 1 && cell.ch.charCodeAt(0) < 0x1100) return;
125
206
  if (cell.ch === "" && x > 0) {
126
207
  const lead = this.cells[offset + x - 1]!;
127
208
  if (graphemeWidth(lead.ch) === 2) lead.ch = " ";
@@ -155,22 +236,65 @@ function sameColor(left: Style["fg"], right: Style["fg"]): boolean {
155
236
  return left.r === right.r && left.g === right.g && left.b === right.b;
156
237
  }
157
238
 
158
- /** Serialise one row with minimal SGR changes. */
239
+ /** True when only the foreground could differ and none of it shows. */
240
+ function invisibleStyle(style: Style | undefined): boolean {
241
+ return !style || (!style.bg && !style.reverse && !style.underline);
242
+ }
243
+
244
+ /** A space whose style draws nothing: its foreground never shows. */
245
+ export function isBlank(cell: Cell): boolean {
246
+ return cell.ch === " " && invisibleStyle(cell.style);
247
+ }
248
+
249
+ /**
250
+ * Serialise one row with minimal SGR changes: a blank keeps the current
251
+ * colour instead of resetting, and a colour-only change writes just the
252
+ * foreground code. Playback rows are mostly dim rules between spaces, so
253
+ * this halves the bytes a frame costs.
254
+ */
159
255
  export function encodeRow(
160
256
  buffer: CellBuffer,
161
257
  y: number,
162
258
  capabilities: TerminalCapabilities,
259
+ from = 0,
260
+ to = buffer.width,
261
+ ): string {
262
+ return encodeSpans(buffer, y, capabilities, [{ from, to }], false);
263
+ }
264
+
265
+ /**
266
+ * Serialise several runs of one row; each run after the first (or every
267
+ * run, with `address`) starts with a cursor move. The colour state carries
268
+ * across runs, since a cursor move leaves it alone.
269
+ */
270
+ export function encodeSpans(
271
+ buffer: CellBuffer,
272
+ y: number,
273
+ capabilities: TerminalCapabilities,
274
+ spans: ReadonlyArray<{ from: number; to: number }>,
275
+ address = true,
163
276
  ): string {
164
277
  let out = "";
165
278
  let current: Style | undefined | null = null;
166
- for (let x = 0; x < buffer.width; x += 1) {
167
- const cell = buffer.get(x, y)!;
168
- if (cell.ch === "") continue;
169
- if (current === null || !sameStyle(current, cell.style)) {
170
- out += styleSgr(cell.style ?? EMPTY, capabilities);
171
- current = cell.style;
279
+ for (const [index, span] of spans.entries()) {
280
+ if (address || index > 0) out += `\u001b[${y + 1};${span.from + 1}H`;
281
+ for (let x = span.from; x < span.to; x += 1) {
282
+ const cell = buffer.get(x, y)!;
283
+ if (cell.ch === "") continue;
284
+ if (current !== null && isBlank(cell) && invisibleStyle(current)) {
285
+ out += " ";
286
+ continue;
287
+ }
288
+ if (current === null || !sameStyle(current, cell.style)) {
289
+ const next = cell.style ?? EMPTY;
290
+ out +=
291
+ (current !== null &&
292
+ foregroundSgr(current ?? EMPTY, next, capabilities)) ||
293
+ styleSgr(next, capabilities);
294
+ current = cell.style;
295
+ }
296
+ out += cell.ch;
172
297
  }
173
- out += cell.ch;
174
298
  }
175
299
  if (capabilities.attributes !== false) out += "\u001b[0m";
176
300
  return out;
@@ -193,9 +317,10 @@ export interface CursorPosition {
193
317
  }
194
318
 
195
319
  /**
196
- * Differential writer: emits cursor-addressed rewrites for changed rows only.
197
- * Output is wrapped in synchronized-update markers, which capable terminals
198
- * use to avoid tearing and others ignore.
320
+ * Differential writer: emits one cursor-addressed rewrite per changed row,
321
+ * covering only that row's changed run of cells. Each frame is one string
322
+ * wrapped in synchronized-update markers, which capable terminals use to
323
+ * avoid tearing and others ignore.
199
324
  */
200
325
  export class ScreenWriter {
201
326
  private previous: CellBuffer | undefined;
@@ -227,8 +352,16 @@ export class ScreenWriter {
227
352
  let rows = 0;
228
353
  if (full) body += "\u001b[0m\u001b[2J";
229
354
  for (let y = 0; y < buffer.height; y += 1) {
230
- if (!full && this.previous!.rowEquals(buffer, y)) continue;
231
- body += `\u001b[${y + 1};1H${encodeRow(buffer, y, this.capabilities)}`;
355
+ if (full) {
356
+ body += `\u001b[${y + 1};1H${encodeRow(buffer, y, this.capabilities)}`;
357
+ rows += 1;
358
+ continue;
359
+ }
360
+ // Only the changed run of cells: a moving playhead or a ticking meter
361
+ // rewrites a few columns, not the whole row.
362
+ const spans = buffer.rowSpans(this.previous!, y);
363
+ if (spans.length === 0) continue;
364
+ body += encodeSpans(buffer, y, this.capabilities, spans);
232
365
  rows += 1;
233
366
  }
234
367
  const cursorKey = cursor ? `${cursor.x},${cursor.y}` : "hidden";
package/tui/tape.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * TAPE (op1-ux §6, §8.3): every track across the bars, as a pure painter.
3
+ *
4
+ * The caller (src/tui/tape-view.ts) turns a score into a `TapeView`: one
5
+ * density level per cell and track, the sections, the loop range, the
6
+ * playhead and the knob slots. This module only draws it, so the layout is
7
+ * testable without a session. Colour is never the only cue: the playhead is
8
+ * `▼`/`│`, the loop `[══]`, the focused row `›`, a muted row dim `·`, a
9
+ * form repeat `░` (ASCII `~`), and every knob carries its shape glyph
10
+ * (NO_COLOR and the mono theme).
11
+ */
12
+ import type { HitMap } from "./hits.ts";
13
+ import { paintKnobStrip, type KnobIndex, type KnobSlots } from "./knobs.ts";
14
+ import type { CellBuffer } from "./screen.ts";
15
+ import { displayWidth, truncate } from "./text.ts";
16
+ import { accentStyle, type Style, type Theme } from "./theme.ts";
17
+
18
+ /** One track row: its density levels (0 empty … 8 dense), one per cell. */
19
+ export type TapeRow = Readonly<{
20
+ id: string;
21
+ name: string;
22
+ muted: boolean;
23
+ levels: readonly number[];
24
+ /** Other panes on this track (§12.7): `B`, `C●`; empty when none. */
25
+ marks: string;
26
+ }>;
27
+
28
+ export type TapeSection = Readonly<{
29
+ name: string;
30
+ startBar: number;
31
+ bars: number;
32
+ }>;
33
+
34
+ /** Cells per bar: half beats, beats or whole bars (`-` / `=` zoom). */
35
+ export type TapeZoom = "half" | "beat" | "bar";
36
+ export const TAPE_ZOOMS: readonly TapeZoom[] = ["bar", "beat", "half"];
37
+
38
+ export type TapeView = Readonly<{
39
+ bars: number;
40
+ /** The bar (0-based) each cell belongs to, in order. */
41
+ cellBars: readonly number[];
42
+ /** Cell under the playhead. */
43
+ playheadCell: number;
44
+ /** The loop range (`score.loop`, or the looped section's bars). */
45
+ loop?: Readonly<{ startBar: number; bars: number }> | undefined;
46
+ sections: readonly TapeSection[];
47
+ /**
48
+ * An unrolled form (op1-ux §4): per cell, whether it is a later pass of a
49
+ * section (ghosted `░`, ASCII `~`), and which form pass it belongs to
50
+ * (-1 for bars the form never plays). Absent without a form.
51
+ */
52
+ ghosts?: readonly boolean[] | undefined;
53
+ passes?: readonly number[] | undefined;
54
+ /** Each form pass's label (`chorus ×2`), indexed by `passes`. */
55
+ passNames?: readonly string[] | undefined;
56
+ /** The looped section's name, drawn reversed. */
57
+ loopSection?: string | undefined;
58
+ /** Tempo changes (`♩96` marks); empty when the tempo is constant. */
59
+ tempo: readonly Readonly<{ cell: number; bpm: number }>[];
60
+ rows: readonly TapeRow[];
61
+ focused: number;
62
+ /** `range: bass · bars 5–6 (loop)`. */
63
+ range: string;
64
+ /** `clipboard: drums · 2 bars`, when the pane holds one. */
65
+ clipboard?: string | undefined;
66
+ knobs: KnobSlots;
67
+ selected: KnobIndex;
68
+ /** The gesture row, from the screen's key table. */
69
+ hint: string;
70
+ /** The reels (`◐◓◑◒`, §9.1), turning on beats while playing. */
71
+ reel?: string | undefined;
72
+ /**
73
+ * The loop just wrapped (a recording pass closed, §9.3): the brackets
74
+ * draw reversed and bold and the fill turns `━`, so it reads in mono.
75
+ */
76
+ loopFlash?: boolean | undefined;
77
+ }>;
78
+
79
+ /** Glyph for a density level, 1 … 8. */
80
+ const LEVELS = " ▁▂▃▄▅▆▇█";
81
+ const ASCII_LEVELS = " .:-=+*#@";
82
+ /** A later pass of a section on an unrolled form. */
83
+ const GHOST = "░";
84
+ const ASCII_GHOST = "~";
85
+
86
+ /** Columns of the row gutter: ` 2›bass `. */
87
+ export const TAPE_GUTTER = 9;
88
+
89
+ /** The rows the tape keeps below the tracks: range, knobs, hints. */
90
+ const FOOTER_ROWS = 3;
91
+
92
+ /** First visible cell: pages so the playhead stays on screen. */
93
+ export function tapeFirstCell(view: TapeView, cells: number): number {
94
+ if (view.cellBars.length <= cells) return 0;
95
+ const page = Math.floor(view.playheadCell / cells);
96
+ return Math.min(page * cells, view.cellBars.length - cells);
97
+ }
98
+
99
+ /** First visible track row: keeps the focused row on screen. */
100
+ export function tapeFirstRow(view: TapeView, rows: number): number {
101
+ if (view.rows.length <= rows) return 0;
102
+ return Math.max(
103
+ 0,
104
+ Math.min(view.focused - rows + 1, view.rows.length - rows),
105
+ );
106
+ }
107
+
108
+ export type TapePaintOptions = Readonly<{
109
+ theme: Theme;
110
+ unicode: boolean;
111
+ hits?: HitMap | undefined;
112
+ }>;
113
+
114
+ /** What the painter laid out, for mouse hits and tests. */
115
+ export type TapeLayout = Readonly<{
116
+ rulerY: number;
117
+ firstCell: number;
118
+ cells: number;
119
+ firstRow: number;
120
+ rowsY: number;
121
+ rowCount: number;
122
+ }>;
123
+
124
+ export function paintTape(
125
+ buffer: CellBuffer,
126
+ rect: Readonly<{ x: number; y: number; width: number; height: number }>,
127
+ view: TapeView,
128
+ options: TapePaintOptions,
129
+ ): TapeLayout {
130
+ const { theme, unicode } = options;
131
+ const roles = theme.roles;
132
+ // ` B C●`: room for up to three pane letters right of the cells.
133
+ const marksWidth = view.rows.some((row) => row.marks) ? 7 : 0;
134
+ const cells = Math.max(
135
+ 0,
136
+ Math.min(view.cellBars.length, rect.width - TAPE_GUTTER - marksWidth),
137
+ );
138
+ const firstCell = tapeFirstCell(view, cells);
139
+ const left = rect.x + TAPE_GUTTER;
140
+ const bottom = rect.y + rect.height;
141
+ const cellBar = (cell: number): number => view.cellBars[cell] ?? view.bars;
142
+ const ghost = (cell: number): boolean => view.ghosts?.[cell] ?? false;
143
+ const passOf = (cell: number): number | undefined => view.passes?.[cell];
144
+ const barStart = (cell: number): boolean =>
145
+ cell === 0 ||
146
+ cellBar(cell) !== cellBar(cell - 1) ||
147
+ passOf(cell) !== passOf(cell - 1);
148
+ const loopStart = view.loop?.startBar;
149
+ const loopEnd = view.loop ? view.loop.startBar + view.loop.bars : undefined;
150
+ // The loop brackets its source bars, never a ghost pass of them.
151
+ const inLoop = (cell: number): boolean =>
152
+ loopStart !== undefined &&
153
+ !ghost(cell) &&
154
+ cellBar(cell) >= loopStart &&
155
+ cellBar(cell) < loopEnd!;
156
+
157
+ // Ruler: bar numbers every 4 bars and at the loop edges; `[══]` in green.
158
+ let y = rect.y;
159
+ const ruler = Array.from({ length: cells }, () => " ");
160
+ const rulerStyle: (Style | undefined)[] = Array.from(
161
+ { length: cells },
162
+ () => roles.muted,
163
+ );
164
+ const flash = view.loopFlash === true;
165
+ const bracketStyle: Style = flash
166
+ ? { ...roles.knob2, bold: true, reverse: true }
167
+ : roles.knob2;
168
+ for (let index = 0; index < cells; index += 1) {
169
+ const cell = firstCell + index;
170
+ if (inLoop(cell)) {
171
+ ruler[index] = flash ? (unicode ? "━" : "#") : unicode ? "═" : "=";
172
+ rulerStyle[index] = flash ? { ...roles.knob2, bold: true } : roles.knob2;
173
+ }
174
+ }
175
+ for (let index = 0; index < cells; index += 1) {
176
+ const cell = firstCell + index;
177
+ if (!barStart(cell)) continue;
178
+ const bar = cellBar(cell);
179
+ const edge = !ghost(cell) && (bar === loopStart || bar + 1 === loopEnd);
180
+ if (bar % 4 !== 0 && !edge && bar !== view.bars - 1) continue;
181
+ const label = String(bar + 1);
182
+ const at = index + (edge && bar === loopStart ? 1 : 0);
183
+ // A number never overwrites a neighbour's digits.
184
+ if (ruler.slice(at, at + label.length).some((ch) => /\d/.test(ch)))
185
+ continue;
186
+ for (let k = 0; k < label.length && at + k < cells; k += 1) {
187
+ ruler[at + k] = label[k]!;
188
+ if (!inLoop(cell)) rulerStyle[at + k] = roles.muted;
189
+ }
190
+ }
191
+ if (view.loop) {
192
+ for (let index = 0; index < cells; index += 1) {
193
+ const cell = firstCell + index;
194
+ if (barStart(cell) && cellBar(cell) === loopStart && !ghost(cell)) {
195
+ ruler[index] = "[";
196
+ rulerStyle[index] = bracketStyle;
197
+ }
198
+ if (inLoop(cell) && !inLoop(cell + 1)) {
199
+ ruler[index] = "]";
200
+ rulerStyle[index] = bracketStyle;
201
+ }
202
+ }
203
+ }
204
+ const head = view.playheadCell - firstCell;
205
+ if (head >= 0 && head < cells) {
206
+ // On a loop bracket the bracket stays and the playhead reverses it, so
207
+ // both read without color (the rows still carry the `│` column).
208
+ if (ruler[head] === "[" || ruler[head] === "]")
209
+ rulerStyle[head] = { ...roles.knob1, reverse: true };
210
+ else {
211
+ ruler[head] = unicode ? "▼" : "v";
212
+ rulerStyle[head] = roles.knob1;
213
+ }
214
+ }
215
+ buffer.text(rect.x, y, " bar", roles.faint);
216
+ // The reels sit in the gutter: a glyph that turns, so never color-only.
217
+ if (view.reel) buffer.text(rect.x + 6, y, view.reel, roles.warning);
218
+ ruler.forEach((ch, index) =>
219
+ buffer.set(left + index, y, ch, rulerStyle[index]),
220
+ );
221
+ const rulerY = y;
222
+ options.hits?.add(left, y, cells, 1, { kind: "tape-ruler", left, firstCell });
223
+ y += 1;
224
+
225
+ // Sections: `▀name▀▀▀`; the looped one reversed; a form repeat `░name░`.
226
+ if (y < bottom - FOOTER_ROWS) {
227
+ buffer.text(rect.x, y, " sect", roles.faint);
228
+ const sectionAt = (cell: number): TapeSection | undefined => {
229
+ const bar = cellBar(cell);
230
+ return view.sections.find(
231
+ (item) => bar >= item.startBar && bar < item.startBar + item.bars,
232
+ );
233
+ };
234
+ const sectionStyle = (section: TapeSection, cell: number): Style => ({
235
+ ...accentStyle(theme, `section:${section.name}`),
236
+ ...(ghost(cell) ? { dim: true } : {}),
237
+ ...(section.name === view.loopSection && !ghost(cell)
238
+ ? { reverse: true }
239
+ : {}),
240
+ });
241
+ for (let index = 0; index < cells; index += 1) {
242
+ const cell = firstCell + index;
243
+ const section = sectionAt(cell);
244
+ if (!section) {
245
+ buffer.set(left + index, y, " ", undefined);
246
+ continue;
247
+ }
248
+ buffer.set(
249
+ left + index,
250
+ y,
251
+ ghost(cell) ? (unicode ? GHOST : ASCII_GHOST) : unicode ? "▀" : "-",
252
+ sectionStyle(section, cell),
253
+ );
254
+ }
255
+ // A label at each section's first cell (each form pass when unrolled).
256
+ for (let index = 0; index < cells; index += 1) {
257
+ const cell = firstCell + index;
258
+ const section = sectionAt(cell);
259
+ if (!section) continue;
260
+ const pass = passOf(cell);
261
+ const starts =
262
+ cell === firstCell
263
+ ? true
264
+ : sectionAt(cell - 1) !== section || pass !== passOf(cell - 1);
265
+ if (!starts) continue;
266
+ let room = 1;
267
+ while (
268
+ index + room < cells &&
269
+ sectionAt(cell + room) === section &&
270
+ passOf(cell + room) === pass
271
+ )
272
+ room += 1;
273
+ const name =
274
+ pass !== undefined && pass >= 0
275
+ ? (view.passNames?.[pass] ?? section.name)
276
+ : section.name;
277
+ // A ghost keeps a leading `░` so the repeat reads in mono too.
278
+ if (ghost(cell) && room <= 2) continue;
279
+ const offset = ghost(cell) ? 1 : 0;
280
+ const label = truncate(name, room - offset, "");
281
+ buffer.text(left + index + offset, y, label, {
282
+ ...sectionStyle(section, cell),
283
+ bold: true,
284
+ });
285
+ }
286
+ y += 1;
287
+ }
288
+
289
+ // Tempo marks, only when the tempo changes.
290
+ if (view.tempo.length > 0 && y < bottom - FOOTER_ROWS) {
291
+ buffer.text(rect.x, y, " tempo", roles.faint);
292
+ for (const mark of view.tempo) {
293
+ const at = mark.cell - firstCell;
294
+ if (at < 0 || at >= cells) continue;
295
+ buffer.text(
296
+ left + at,
297
+ y,
298
+ truncate(`${unicode ? "♩" : "q"}${mark.bpm}`, cells - at, ""),
299
+ roles.knob3,
300
+ );
301
+ }
302
+ y += 1;
303
+ }
304
+
305
+ // Track rows.
306
+ const rowsY = y;
307
+ const rowRoom = Math.max(0, bottom - FOOTER_ROWS - y);
308
+ const firstRow = tapeFirstRow(view, rowRoom);
309
+ const levels = unicode ? LEVELS : ASCII_LEVELS;
310
+ const visible = view.rows.slice(firstRow, firstRow + rowRoom);
311
+ visible.forEach((row, offset) => {
312
+ const index = firstRow + offset;
313
+ const focused = index === view.focused;
314
+ const number = index < 9 ? String(index + 1) : " ";
315
+ const accent = accentStyle(theme, row.id);
316
+ buffer.text(rect.x, y, ` ${number}`, roles.faint);
317
+ buffer.text(rect.x + 2, y, focused ? (unicode ? "›" : ">") : " ", {
318
+ ...roles.text,
319
+ bold: true,
320
+ });
321
+ const name = truncate(row.name, 6, "").padEnd(6);
322
+ buffer.text(
323
+ rect.x + 3,
324
+ y,
325
+ name,
326
+ focused
327
+ ? { ...accent, bold: true, reverse: true }
328
+ : row.muted
329
+ ? roles.mutedNote
330
+ : accent,
331
+ );
332
+ for (let column = 0; column < cells; column += 1) {
333
+ const cell = firstCell + column;
334
+ const level = row.levels[cell] ?? 0;
335
+ const repeat = ghost(cell);
336
+ const style: Style | undefined =
337
+ column === head
338
+ ? // Over a note the playhead reverses it: visible in mono too.
339
+ { ...roles.knob1, ...(level > 0 ? { reverse: true } : {}) }
340
+ : row.muted
341
+ ? roles.mutedNote
342
+ : level > 0
343
+ ? repeat
344
+ ? { ...accent, dim: true }
345
+ : accent
346
+ : barStart(cell)
347
+ ? roles.barRule
348
+ : roles.beatRule;
349
+ const ch =
350
+ level > 0
351
+ ? repeat
352
+ ? unicode
353
+ ? GHOST
354
+ : ASCII_GHOST
355
+ : levels[Math.min(8, level)]!
356
+ : column === head
357
+ ? unicode
358
+ ? "│"
359
+ : "|"
360
+ : barStart(cell)
361
+ ? unicode
362
+ ? "┊"
363
+ : ":"
364
+ : "·";
365
+ buffer.set(left + column, y, ch, style);
366
+ }
367
+ // Other panes, dim; a recording pane's `●` in the record red.
368
+ [
369
+ ...truncate(
370
+ unicode ? row.marks : row.marks.replace(/●/g, "*"),
371
+ marksWidth - 1,
372
+ "",
373
+ ),
374
+ ].forEach((ch, k) =>
375
+ buffer.set(
376
+ left + cells + 1 + k,
377
+ y,
378
+ ch,
379
+ ch === "●" || ch === "*" ? roles.error : { ...roles.muted, dim: true },
380
+ ),
381
+ );
382
+ options.hits?.add(rect.x, y, rect.width, 1, {
383
+ kind: "tape-row",
384
+ row: index,
385
+ left,
386
+ firstCell,
387
+ });
388
+ y += 1;
389
+ });
390
+
391
+ // Footer: range line with the clipboard chip, knob strip, gesture hints.
392
+ const footerY = bottom - FOOTER_ROWS;
393
+ if (footerY >= rowsY) {
394
+ buffer.text(
395
+ rect.x + 1,
396
+ footerY,
397
+ truncate(view.range, rect.width - 2),
398
+ roles.text,
399
+ );
400
+ if (view.clipboard) {
401
+ const chip = `${unicode ? "◆" : "*"} ${view.clipboard}`;
402
+ const room = rect.width - 2 - displayWidth(view.range) - 3;
403
+ if (room >= 12) {
404
+ const text = truncate(chip, room);
405
+ const x = rect.x + rect.width - 1 - displayWidth(text);
406
+ buffer.text(x, footerY, text, roles.warning);
407
+ options.hits?.add(x, footerY, displayWidth(text), 1, {
408
+ kind: "tape-clipboard",
409
+ });
410
+ }
411
+ }
412
+ paintKnobStrip(
413
+ buffer,
414
+ rect.x + 1,
415
+ footerY + 1,
416
+ rect.width - 2,
417
+ view.knobs,
418
+ view.selected,
419
+ {
420
+ theme,
421
+ unicode,
422
+ },
423
+ );
424
+ buffer.text(
425
+ rect.x + 1,
426
+ footerY + 2,
427
+ truncate(view.hint, rect.width - 2),
428
+ roles.muted,
429
+ );
430
+ }
431
+ return {
432
+ rulerY,
433
+ firstCell,
434
+ cells,
435
+ firstRow,
436
+ rowsY,
437
+ rowCount: visible.length,
438
+ };
439
+ }