@hraness/dawg 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
package/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;
@@ -51,7 +61,8 @@ export type ChordLegendCell = Readonly<{
51
61
  /** Text of the header row, for tests and narrow terminals. */
52
62
  export function playHeaderText(view: PlayHeaderView, unicode = true): string {
53
63
  const parts = [
54
- "PLAY",
64
+ "PLAY MODE",
65
+ view.context ?? "",
55
66
  view.range,
56
67
  view.armed
57
68
  ? `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`
@@ -87,8 +98,12 @@ export function paintPlayHeader(
87
98
  );
88
99
  x += buffer.text(x, y, " ", panel);
89
100
  };
90
- put(" PLAY ", roles.pillSteer);
91
- put(view.range, { ...roles.text, bold: true });
101
+ put(" PLAY MODE ", roles.pillSteer);
102
+ const context = fitContext(view, width, unicode);
103
+ if (context) put(context, roles.muted);
104
+ // A kit names its range by the track already in the context.
105
+ if (!context.includes(" · ") || view.range !== "drums")
106
+ put(view.range, { ...roles.text, bold: true });
92
107
  if (view.armed)
93
108
  put(
94
109
  `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`,
@@ -99,7 +114,8 @@ export function paintPlayHeader(
99
114
  if (view.sustain) put("SUSTAIN", roles.pillQueue);
100
115
  if (view.chords) put(view.chords, roles.hit);
101
116
  if (view.countIn) put(view.countIn, roles.warning);
102
- if (view.beat) {
117
+ const hintWidth = displayWidth("? keys · esc leave");
118
+ if (view.beat && x + view.beat.of + 2 <= width - 1 - hintWidth) {
103
119
  const cells = Array.from({ length: view.beat.of }, (_, index) =>
104
120
  index + 1 === view.beat!.index
105
121
  ? unicode
@@ -136,6 +152,31 @@ export function paintPlayHeader(
136
152
  if (right > x) buffer.text(right, y, hint, onBackground(roles.faint, panel));
137
153
  }
138
154
 
155
+ /**
156
+ * The compressed song header (`▶ 120 BPM · bass · rev 1`). It is reserved
157
+ * first: the beat dots and the status give way before it does, and only a
158
+ * terminal too narrow for it with the badge and the way out shortens it to
159
+ * its first clause.
160
+ */
161
+ function fitContext(
162
+ view: PlayHeaderView,
163
+ width: number,
164
+ unicode: boolean,
165
+ ): string {
166
+ if (!view.context) return "";
167
+ const rest = displayWidth(
168
+ playHeaderText(
169
+ { ...view, context: undefined, status: undefined, beat: undefined },
170
+ unicode,
171
+ ),
172
+ );
173
+ // Badge padding, the right-hand hint and the gaps between parts.
174
+ const room = width - 2 - displayWidth("? keys · esc leave") - 4 - rest;
175
+ for (const candidate of [view.context, view.context.split(" · ")[0]!])
176
+ if (displayWidth(candidate) + 2 <= room) return candidate;
177
+ return "";
178
+ }
179
+
139
180
  /** Chord mode's number row: `1 dim 2 min … 9 block b bass off n next`. */
140
181
  export function paintChordLegend(
141
182
  buffer: CellBuffer,
@@ -162,7 +203,11 @@ export function paintChordLegend(
162
203
  }
163
204
  }
164
205
 
165
- /** `a C3 │ w C#3 …`: each key with its note; lit keys reversed. */
206
+ /**
207
+ * `A C3 W C#3 …`: each key with what it plays. The key letter is muted so
208
+ * the music reads first; a chord label is bold, and chords in the song's
209
+ * key are lit (they always fit). Sounding keys are reversed.
210
+ */
166
211
  export function paintPlayStrip(
167
212
  buffer: CellBuffer,
168
213
  y: number,
@@ -174,15 +219,24 @@ export function paintPlayStrip(
174
219
  buffer.fill(0, y, width, 1, roles.canvas);
175
220
  let x = 1;
176
221
  for (const key of keys) {
177
- const cell = `${key.key.toUpperCase()} ${key.label}`;
178
- const cellWidth = displayWidth(cell) + 1;
222
+ const letter = key.key.toUpperCase();
223
+ const cellWidth = displayWidth(letter) + 1 + displayWidth(key.label) + 1;
179
224
  if (x + cellWidth > width - 1) break;
180
- const style = key.lit
225
+ const labelStyle: Style = key.lit
181
226
  ? { ...(key.black ? roles.selected : roles.hit), reverse: true }
227
+ : key.chord
228
+ ? { ...(key.diatonic ? roles.hit : roles.text), bold: true }
229
+ : key.black
230
+ ? roles.muted
231
+ : roles.text;
232
+ const letterStyle: Style = key.lit
233
+ ? labelStyle
182
234
  : key.black
183
- ? roles.muted
184
- : roles.text;
185
- buffer.text(x, y, cell, style);
186
- x += cellWidth;
235
+ ? roles.faint
236
+ : roles.muted;
237
+ x += buffer.text(x, y, letter, letterStyle);
238
+ x += buffer.text(x, y, " ", key.lit ? labelStyle : roles.canvas);
239
+ x += buffer.text(x, y, key.label, labelStyle);
240
+ x += 1;
187
241
  }
188
242
  }
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");
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;
@@ -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;
@@ -155,22 +233,65 @@ function sameColor(left: Style["fg"], right: Style["fg"]): boolean {
155
233
  return left.r === right.r && left.g === right.g && left.b === right.b;
156
234
  }
157
235
 
158
- /** Serialise one row with minimal SGR changes. */
236
+ /** True when only the foreground could differ and none of it shows. */
237
+ function invisibleStyle(style: Style | undefined): boolean {
238
+ return !style || (!style.bg && !style.reverse && !style.underline);
239
+ }
240
+
241
+ /** A space whose style draws nothing: its foreground never shows. */
242
+ export function isBlank(cell: Cell): boolean {
243
+ return cell.ch === " " && invisibleStyle(cell.style);
244
+ }
245
+
246
+ /**
247
+ * Serialise one row with minimal SGR changes: a blank keeps the current
248
+ * colour instead of resetting, and a colour-only change writes just the
249
+ * foreground code. Playback rows are mostly dim rules between spaces, so
250
+ * this halves the bytes a frame costs.
251
+ */
159
252
  export function encodeRow(
160
253
  buffer: CellBuffer,
161
254
  y: number,
162
255
  capabilities: TerminalCapabilities,
256
+ from = 0,
257
+ to = buffer.width,
258
+ ): string {
259
+ return encodeSpans(buffer, y, capabilities, [{ from, to }], false);
260
+ }
261
+
262
+ /**
263
+ * Serialise several runs of one row; each run after the first (or every
264
+ * run, with `address`) starts with a cursor move. The colour state carries
265
+ * across runs, since a cursor move leaves it alone.
266
+ */
267
+ export function encodeSpans(
268
+ buffer: CellBuffer,
269
+ y: number,
270
+ capabilities: TerminalCapabilities,
271
+ spans: ReadonlyArray<{ from: number; to: number }>,
272
+ address = true,
163
273
  ): string {
164
274
  let out = "";
165
275
  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;
276
+ for (const [index, span] of spans.entries()) {
277
+ if (address || index > 0) out += `\u001b[${y + 1};${span.from + 1}H`;
278
+ for (let x = span.from; x < span.to; x += 1) {
279
+ const cell = buffer.get(x, y)!;
280
+ if (cell.ch === "") continue;
281
+ if (current !== null && isBlank(cell) && invisibleStyle(current)) {
282
+ out += " ";
283
+ continue;
284
+ }
285
+ if (current === null || !sameStyle(current, cell.style)) {
286
+ const next = cell.style ?? EMPTY;
287
+ out +=
288
+ (current !== null &&
289
+ foregroundSgr(current ?? EMPTY, next, capabilities)) ||
290
+ styleSgr(next, capabilities);
291
+ current = cell.style;
292
+ }
293
+ out += cell.ch;
172
294
  }
173
- out += cell.ch;
174
295
  }
175
296
  if (capabilities.attributes !== false) out += "\u001b[0m";
176
297
  return out;
@@ -193,9 +314,10 @@ export interface CursorPosition {
193
314
  }
194
315
 
195
316
  /**
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.
317
+ * Differential writer: emits one cursor-addressed rewrite per changed row,
318
+ * covering only that row's changed run of cells. Each frame is one string
319
+ * wrapped in synchronized-update markers, which capable terminals use to
320
+ * avoid tearing and others ignore.
199
321
  */
200
322
  export class ScreenWriter {
201
323
  private previous: CellBuffer | undefined;
@@ -227,8 +349,16 @@ export class ScreenWriter {
227
349
  let rows = 0;
228
350
  if (full) body += "\u001b[0m\u001b[2J";
229
351
  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)}`;
352
+ if (full) {
353
+ body += `\u001b[${y + 1};1H${encodeRow(buffer, y, this.capabilities)}`;
354
+ rows += 1;
355
+ continue;
356
+ }
357
+ // Only the changed run of cells: a moving playhead or a ticking meter
358
+ // rewrites a few columns, not the whole row.
359
+ const spans = buffer.rowSpans(this.previous!, y);
360
+ if (spans.length === 0) continue;
361
+ body += encodeSpans(buffer, y, this.capabilities, spans);
232
362
  rows += 1;
233
363
  }
234
364
  const cursorKey = cursor ? `${cursor.x},${cursor.y}` : "hidden";
package/tui/theme.ts CHANGED
@@ -425,6 +425,33 @@ export function styleSgr(
425
425
  return sequence;
426
426
  }
427
427
 
428
+ /**
429
+ * The shortest SGR that moves the terminal from `from` to `to` when they
430
+ * differ only in foreground colour: `38;…` or `39`, never a full reset.
431
+ * Undefined when anything else differs (the caller emits `styleSgr`).
432
+ */
433
+ export function foregroundSgr(
434
+ from: Style,
435
+ to: Style,
436
+ capabilities: TerminalCapabilities,
437
+ ): string | undefined {
438
+ if (capabilities.attributes === false) return "";
439
+ if (
440
+ !!from.bold !== !!to.bold ||
441
+ !!from.dim !== !!to.dim ||
442
+ !!from.italic !== !!to.italic ||
443
+ !!from.underline !== !!to.underline ||
444
+ !!from.reverse !== !!to.reverse
445
+ )
446
+ return undefined;
447
+ const fromBg = from.bg && colorCode(from.bg, capabilities.colorDepth, true);
448
+ const toBg = to.bg && colorCode(to.bg, capabilities.colorDepth, true);
449
+ if (fromBg !== toBg) return undefined;
450
+ if (!to.fg) return "\u001b[39m";
451
+ const code = colorCode(to.fg, capabilities.colorDepth, false);
452
+ return code ? `\u001b[${code}m` : "\u001b[39m";
453
+ }
454
+
428
455
  /** Resolve the effective theme: no-color terminals always use attributes only. */
429
456
  export function effectiveTheme(
430
457
  name: ThemeName,