@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/tui/app.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  type TrackScoreSnapshot,
26
26
  } from "./highway.ts";
27
27
  import { asciiHint, fitHint, HINTS } from "./grammar.ts";
28
+ import { GuideBrowser } from "./guide.ts";
29
+ import { listGuides } from "../guides/index.ts";
28
30
  import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
29
31
  import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
30
32
  import {
@@ -33,7 +35,10 @@ import {
33
35
  paintPlayStrip,
34
36
  type PlayHeaderView,
35
37
  } from "./play-strip.ts";
38
+ import { paintArrangeStrip, type ArrangeStripView } from "./arrange-strip.ts";
36
39
  import { CellBuffer, ScreenWriter, type CursorPosition } from "./screen.ts";
40
+ import { paintDrawer, type DrawerLayout, type DrawerView } from "./drawer.ts";
41
+ import { HitMap, type HitTarget } from "./hits.ts";
37
42
  import { displayWidth, truncate } from "./text.ts";
38
43
  import {
39
44
  accentStyle,
@@ -72,6 +77,44 @@ export interface AppView {
72
77
  agentOffline?: boolean | undefined;
73
78
  /** Play mode: replaces the header and adds the keyboard strip row. */
74
79
  play?: PlayHeaderView | undefined;
80
+ /** Song master meter: integrated LUFS and true peak of the playing loop. */
81
+ loudness?: LoudnessView | undefined;
82
+ /** Song sections over the timeline (0.5); no row when absent. */
83
+ arrange?: ArrangeStripView | undefined;
84
+ }
85
+
86
+ export type LoudnessView = Readonly<{
87
+ /** Integrated loudness, LUFS. */
88
+ integrated: number;
89
+ /** True peak, dBTP. */
90
+ truePeak: number;
91
+ /** The master's loudness target, when one is set. */
92
+ target?: number | undefined;
93
+ /** The limiter's ceiling, dBTP, when the limiter is on. */
94
+ ceiling?: number | undefined;
95
+ /** A monitor-rate reading shown until the export-rate one is in (`~`). */
96
+ estimate?: boolean | undefined;
97
+ }>;
98
+
99
+ /**
100
+ * `-14.1 LUFS (-14) · TP -1.0`: the compact loudness meter, with the
101
+ * master's target in brackets when one is set and `~` before a monitor-rate
102
+ * estimate. Loudness below -70 LUFS (silence) reads `-∞`.
103
+ */
104
+ export function loudnessMeter(view: LoudnessView): string {
105
+ const lufs = view.integrated <= -70 ? "-∞" : view.integrated.toFixed(1);
106
+ const peak = view.truePeak <= -119 ? "-∞" : view.truePeak.toFixed(1);
107
+ const target = view.target === undefined ? "" : ` (${view.target})`;
108
+ return `${view.estimate ? "~" : ""}${lufs} LUFS${target} · TP ${peak}`;
109
+ }
110
+
111
+ /** Whether the meter's reading is off target by more than 0.5 LU. */
112
+ export function loudnessOffTarget(view: LoudnessView): boolean {
113
+ return (
114
+ view.target !== undefined &&
115
+ view.integrated > -70 &&
116
+ Math.abs(view.integrated - view.target) > 0.5
117
+ );
75
118
  }
76
119
 
77
120
  export type TypesIndicator = Readonly<{ ok: boolean; errors: number }>;
@@ -89,10 +132,17 @@ export interface UiState {
89
132
  text?: TextView | undefined;
90
133
  /** The `?` panel: keys for the screen underneath, drawn over it. */
91
134
  keys?: TextView | undefined;
135
+ /** The `/guide` tree and pages. */
136
+ guide?: GuideBrowser | undefined;
137
+ /** The fader drawer, docked over the bottom of the piano roll. */
138
+ drawer?: DrawerView | undefined;
92
139
  }
93
140
 
94
- /** `log` is the transcript, `picker` an arrow-key list, `text` static lines. */
95
- export type Overlay = "log" | "picker" | "text" | undefined;
141
+ /**
142
+ * `log` is the transcript, `picker` an arrow-key list, `text` static lines,
143
+ * `guide` the user guides.
144
+ */
145
+ export type Overlay = "log" | "picker" | "text" | "guide" | undefined;
96
146
 
97
147
  /** A scrollable read-only panel (`/help`, `/sessions`, `/tracks`). */
98
148
  export interface TextView {
@@ -179,6 +229,10 @@ export interface Frame {
179
229
  /** Rows used by the prompt editor (excluding borders). */
180
230
  promptRows: number;
181
231
  layout: FrameLayout;
232
+ /** Click targets, recorded while painting (topmost last). */
233
+ hits: HitMap;
234
+ /** Where the fader drawer landed, when one is open. */
235
+ drawer?: DrawerLayout | undefined;
182
236
  }
183
237
 
184
238
  export interface FrameLayout {
@@ -261,6 +315,8 @@ interface Segment {
261
315
  style: Style;
262
316
  /** Lower numbers survive longer when space is short. */
263
317
  priority: number;
318
+ /** What a click on it does. */
319
+ target?: HitTarget | undefined;
264
320
  }
265
321
 
266
322
  function paintSegments(
@@ -272,6 +328,7 @@ function paintSegments(
272
328
  separatorStyle: Style,
273
329
  background: Style,
274
330
  rightSegments: Segment[] = [],
331
+ hits?: HitMap,
275
332
  ): void {
276
333
  const sepWidth = displayWidth(separator);
277
334
  const total = (list: Segment[]) =>
@@ -298,12 +355,14 @@ function paintSegments(
298
355
  separator,
299
356
  onBackground(separatorStyle, background),
300
357
  );
301
- x += buffer.text(
358
+ const used = buffer.text(
302
359
  x,
303
360
  y,
304
361
  truncate(segment.text, Math.max(1, width - x - 1)),
305
362
  onBackground(segment.style, background),
306
363
  );
364
+ if (segment.target) hits?.add(x, y, used, 1, segment.target);
365
+ x += used;
307
366
  });
308
367
  if (right.length) {
309
368
  let rx = width - 1 - total(right);
@@ -316,12 +375,14 @@ function paintSegments(
316
375
  separator,
317
376
  onBackground(separatorStyle, background),
318
377
  );
319
- rx += buffer.text(
378
+ const used = buffer.text(
320
379
  rx,
321
380
  y,
322
381
  segment.text,
323
382
  onBackground(segment.style, background),
324
383
  );
384
+ if (segment.target) hits?.add(rx, y, used, 1, segment.target);
385
+ rx += used;
325
386
  });
326
387
  }
327
388
  }
@@ -356,6 +417,7 @@ function paintHeader(
356
417
  view: AppView,
357
418
  ui: UiState,
358
419
  width: number,
420
+ hits?: HitMap,
359
421
  ): void {
360
422
  const { theme, capabilities } = ui;
361
423
  const roles = theme.roles;
@@ -370,11 +432,13 @@ function paintHeader(
370
432
  text: name,
371
433
  style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
372
434
  priority: 0,
435
+ target: { kind: "tracks" },
373
436
  },
374
437
  {
375
438
  text: transport,
376
439
  style: playing ? roles.transport : roles.paused,
377
440
  priority: 0,
441
+ target: { kind: "transport" },
378
442
  },
379
443
  ];
380
444
  if (score.key)
@@ -395,7 +459,12 @@ function paintHeader(
395
459
  });
396
460
  const right: Segment[] = [];
397
461
  if (view.model)
398
- right.push({ text: view.model, style: roles.agent, priority: 2 });
462
+ right.push({
463
+ text: view.model,
464
+ style: roles.agent,
465
+ priority: 2,
466
+ target: { kind: "model" },
467
+ });
399
468
  if (score.revision !== undefined)
400
469
  right.push({
401
470
  text: `rev ${score.revision}`,
@@ -410,9 +479,33 @@ function paintHeader(
410
479
  style: view.types.ok ? roles.success : roles.error,
411
480
  priority: 4,
412
481
  });
482
+ if (view.loudness)
483
+ right.push({
484
+ text: loudnessMeter(view.loudness),
485
+ // Over full scale is an error; above the limiter's ceiling (-1 dBTP
486
+ // without one) or off the target by more than 0.5 LU a warning.
487
+ style:
488
+ view.loudness.truePeak > 0
489
+ ? roles.error
490
+ : view.loudness.truePeak > (view.loudness.ceiling ?? -1) + 0.1 ||
491
+ loudnessOffTarget(view.loudness)
492
+ ? roles.warning
493
+ : roles.muted,
494
+ priority: 3,
495
+ });
413
496
  const sync = syncSegment(view.sync, theme, unicode);
414
497
  if (sync) right.push(sync);
415
- paintSegments(buffer, 0, left, width, " · ", roles.faint, roles.panel, right);
498
+ paintSegments(
499
+ buffer,
500
+ 0,
501
+ left,
502
+ width,
503
+ " · ",
504
+ roles.faint,
505
+ roles.panel,
506
+ right,
507
+ hits,
508
+ );
416
509
  }
417
510
 
418
511
  // ---------------------------------------------------------------------------
@@ -818,6 +911,61 @@ function paintText(
818
911
  });
819
912
  }
820
913
 
914
+ function paintGuide(
915
+ buffer: CellBuffer,
916
+ ui: UiState,
917
+ region: { y: number; height: number },
918
+ width: number,
919
+ ): void {
920
+ const guide = ui.guide;
921
+ if (!guide) return;
922
+ const roles = ui.theme.roles;
923
+ const unicode = ui.capabilities.unicode;
924
+ const left = width >= 60 ? 2 : 0;
925
+ const boxWidth = width - left * 2;
926
+ const height = region.height;
927
+ if (height < 3 || boxWidth < 10) return;
928
+ const panel = paintBox(buffer, ui, {
929
+ left,
930
+ top: region.y,
931
+ width: boxWidth,
932
+ height,
933
+ });
934
+ const view = guide.view(boxWidth - 4, height - 2, unicode);
935
+ buffer.text(
936
+ left + 2,
937
+ region.y,
938
+ ` ${view.title} `,
939
+ onBackground({ ...roles.text, bold: true }, panel),
940
+ boxWidth - 4,
941
+ );
942
+ const hint = footerHint(view.hint, boxWidth - 4, unicode);
943
+ if (hint)
944
+ buffer.text(
945
+ left + boxWidth - 2 - displayWidth(hint),
946
+ region.y + height - 1,
947
+ hint,
948
+ onBackground(roles.muted, panel),
949
+ );
950
+ view.rows.forEach((row, index) => {
951
+ const style = row.selected
952
+ ? { ...roles.borderFocus, bold: true }
953
+ : row.heading
954
+ ? { ...roles.borderFocus, bold: true }
955
+ : row.muted
956
+ ? roles.muted
957
+ : roles.text;
958
+ const marker = row.selected ? (unicode ? "›" : ">") : " ";
959
+ const text = guide.page === undefined ? `${marker}${row.text}` : row.text;
960
+ buffer.text(
961
+ left + 2,
962
+ region.y + 1 + index,
963
+ truncate(text, boxWidth - 4),
964
+ onBackground(style, panel),
965
+ );
966
+ });
967
+ }
968
+
821
969
  function paintBox(
822
970
  buffer: CellBuffer,
823
971
  ui: UiState,
@@ -849,6 +997,7 @@ function paintPicker(
849
997
  ui: UiState,
850
998
  region: { y: number; height: number },
851
999
  width: number,
1000
+ hits?: HitMap,
852
1001
  ): void {
853
1002
  const picker = ui.picker;
854
1003
  if (!picker) return;
@@ -916,6 +1065,7 @@ function paintPicker(
916
1065
  picker.items.slice(first, first + inner).forEach((item, offset) => {
917
1066
  const index = first + offset;
918
1067
  const y = region.y + 1 + offset;
1068
+ hits?.add(left + 1, y, boxWidth - 2, 1, { kind: "picker-row", index });
919
1069
  const selected = index === picker.index;
920
1070
  const marker = selected ? (ui.capabilities.unicode ? "›" : ">") : " ";
921
1071
  const style = selected
@@ -998,9 +1148,16 @@ export function composeFrame(
998
1148
  const buffer = new CellBuffer(width, height, ui.theme.roles.canvas);
999
1149
  ui.prompt.setWidth(promptEditorWidth(width));
1000
1150
  const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
1151
+ const hits = new HitMap();
1001
1152
  if (layout.tooSmall) {
1002
1153
  paintTooSmall(buffer, ui, { width, height });
1003
- return { buffer, cursor: undefined, promptRows: 0, layout };
1154
+ return {
1155
+ buffer,
1156
+ cursor: undefined,
1157
+ promptRows: 0,
1158
+ layout,
1159
+ hits: new HitMap(),
1160
+ };
1004
1161
  }
1005
1162
  if (view.play) {
1006
1163
  paintPlayHeader(
@@ -1031,14 +1188,36 @@ export function composeFrame(
1031
1188
  height: layout.highway.height - 1,
1032
1189
  };
1033
1190
  }
1034
- } else paintHeader(buffer, view, ui, width);
1191
+ } else paintHeader(buffer, view, ui, width, hits);
1192
+ if (view.arrange && layout.highway.height > 4) {
1193
+ paintArrangeStrip(
1194
+ buffer,
1195
+ layout.highway.y,
1196
+ width,
1197
+ view.arrange,
1198
+ ui.theme,
1199
+ ui.capabilities.unicode,
1200
+ );
1201
+ layout.highway = {
1202
+ y: layout.highway.y + 1,
1203
+ height: layout.highway.height - 1,
1204
+ };
1205
+ }
1035
1206
  const beat = view.beat ?? resolveBeat(view.score, nowMs);
1207
+ let drawer: DrawerLayout | undefined;
1036
1208
  if (layout.highway.height > 0) {
1037
- if (ui.overlay === "log") paintOverlay(buffer, ui, layout.highway, width);
1038
- else if (ui.overlay === "picker" && ui.picker)
1039
- paintPicker(buffer, ui, layout.highway, width);
1209
+ hits.add(0, layout.highway.y, width, layout.highway.height, {
1210
+ kind: ui.overlay && !ui.drawer ? "text" : "highway",
1211
+ });
1212
+ // The drawer replaces the menu's list: the piano roll shows above it.
1213
+ if (ui.overlay === "log" && !ui.drawer)
1214
+ paintOverlay(buffer, ui, layout.highway, width);
1215
+ else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
1216
+ paintPicker(buffer, ui, layout.highway, width, hits);
1040
1217
  else if (ui.overlay === "text" && ui.text)
1041
1218
  paintText(buffer, ui, layout.highway, width);
1219
+ else if (ui.overlay === "guide" && ui.guide)
1220
+ paintGuide(buffer, ui, layout.highway, width);
1042
1221
  else
1043
1222
  paintHighway(
1044
1223
  buffer,
@@ -1051,6 +1230,15 @@ export function composeFrame(
1051
1230
  reducedMotion: ui.reducedMotion,
1052
1231
  },
1053
1232
  );
1233
+ if (ui.drawer)
1234
+ drawer = paintDrawer(
1235
+ buffer,
1236
+ layout.highway,
1237
+ width,
1238
+ ui.drawer,
1239
+ { theme: ui.theme, unicode: ui.capabilities.unicode },
1240
+ hits,
1241
+ );
1054
1242
  }
1055
1243
  if (ui.keys && layout.highway.height > 0)
1056
1244
  paintText(buffer, ui, keysRegion(layout.highway, ui.keys), width, {
@@ -1059,7 +1247,14 @@ export function composeFrame(
1059
1247
  });
1060
1248
  paintActivity(buffer, layout.activity, ui, width, nowMs);
1061
1249
  const prompt = paintPrompt(buffer, view, ui, layout, width);
1062
- return { buffer, cursor: prompt.cursor, promptRows: prompt.rows, layout };
1250
+ return {
1251
+ buffer,
1252
+ cursor: prompt.cursor,
1253
+ promptRows: prompt.rows,
1254
+ layout,
1255
+ hits,
1256
+ drawer,
1257
+ };
1063
1258
  }
1064
1259
 
1065
1260
  // ---------------------------------------------------------------------------
@@ -1112,6 +1307,8 @@ export class TuiApp {
1112
1307
  log: LogView = { scroll: 0, filter: "all" };
1113
1308
  picker: PickerState | undefined;
1114
1309
  text: TextView | undefined;
1310
+ /** The fader drawer's paint model while one is open. */
1311
+ drawer: DrawerView | undefined;
1115
1312
  /** `/view all` overlays every unmuted track; `/view focus` shows one. */
1116
1313
  highwayView: "all" | "focus" = "all";
1117
1314
  private writer: ScreenWriter;
@@ -1149,6 +1346,8 @@ export class TuiApp {
1149
1346
  picker: this.picker,
1150
1347
  text: this.text,
1151
1348
  keys: this.keys,
1349
+ guide: this.guide,
1350
+ drawer: this.drawer,
1152
1351
  };
1153
1352
  }
1154
1353
 
@@ -1272,6 +1471,18 @@ export class TuiApp {
1272
1471
  }
1273
1472
  return { type: "overlay" };
1274
1473
  }
1474
+ if (this.overlay === "guide" && this.guide) {
1475
+ if (
1476
+ key.type === "ui" &&
1477
+ (key.command === "quit" || key.command === "redraw")
1478
+ ) {
1479
+ if (key.command === "redraw") this.invalidate();
1480
+ return { type: "ui", command: key.command };
1481
+ }
1482
+ const result = this.guide.key(value, Math.max(1, this.io.rows() - 10));
1483
+ if (result === "close") this.closeGuide();
1484
+ return { type: "overlay" };
1485
+ }
1275
1486
  if (this.overlay === "text" && this.text) {
1276
1487
  const nav = overlayKey(value);
1277
1488
  const page = Math.max(1, this.io.rows() - 10);
@@ -1434,13 +1645,42 @@ export class TuiApp {
1434
1645
  return this.overlay === "picker" && this.picker?.filtering === true;
1435
1646
  }
1436
1647
 
1648
+ /** The `/guide` pane; built on first use from `guides/*.md`. */
1649
+ guide: GuideBrowser | undefined;
1650
+
1651
+ /**
1652
+ * Open the guides at the tree, or at one guide (`chords`). Returns false
1653
+ * (and opens nothing) when `topic` names no guide.
1654
+ */
1655
+ openGuide(topic?: string): boolean {
1656
+ const browser = this.guide ?? new GuideBrowser(listGuides());
1657
+ if (topic && !browser.open(topic)) return false;
1658
+ if (!topic) {
1659
+ browser.page = undefined;
1660
+ browser.query = "";
1661
+ browser.filtering = false;
1662
+ }
1663
+ this.guide = browser;
1664
+ this.overlay = "guide";
1665
+ return true;
1666
+ }
1667
+
1668
+ closeGuide(): void {
1669
+ if (this.overlay === "guide") this.overlay = undefined;
1670
+ }
1671
+
1672
+ /** True while the guide filter takes typed text (so `?` is a letter). */
1673
+ get guideTyping(): boolean {
1674
+ return this.overlay === "guide" && this.guide?.typing === true;
1675
+ }
1676
+
1437
1677
  closeText(): void {
1438
1678
  this.text = undefined;
1439
1679
  if (this.overlay === "text") this.overlay = undefined;
1440
1680
  }
1441
1681
 
1442
1682
  /**
1443
- * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`).
1683
+ * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`, `/guide`).
1444
1684
  * Returns a receipt, or undefined when the command is not a UI command.
1445
1685
  */
1446
1686
  command(text: string): string | undefined {
@@ -1464,6 +1704,15 @@ export class TuiApp {
1464
1704
  ? `theme ${name} · terminal has no color, showing mono`
1465
1705
  : `theme ${name}`;
1466
1706
  }
1707
+ const guide = command.match(/^\/guides?(?:\s+(.+))?$/i);
1708
+ if (guide) {
1709
+ const topic = guide[1]?.trim();
1710
+ if (!this.openGuide(topic))
1711
+ return `no guide named ${topic} · /guide lists them all`;
1712
+ return topic
1713
+ ? `guide · ${this.guide?.guides.find((g) => g.id === this.guide?.page)?.title ?? topic} · esc back`
1714
+ : "guides · → open · esc closes";
1715
+ }
1467
1716
  const view = command.match(/^\/view(?:\s+(\S+))?$/i);
1468
1717
  if (view) {
1469
1718
  const value = view[1]?.toLowerCase();
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The arrangement strip (0.5): one row under the header that shows the
3
+ * song's sections over the bar timeline, like the arranger track in Logic,
4
+ * Cubase or Studio One. Each section is a block with its name; the looped
5
+ * section is reversed, the one under the playhead is bold, and the playhead
6
+ * is a caret. Bars with no section are a faint rule. The strip is absent
7
+ * when the song has no sections, so a 0.4 screen keeps every row it had.
8
+ *
9
+ * The shape is structural so the TUI does not depend on core.
10
+ */
11
+ import type { CellBuffer } from "./screen.ts";
12
+ import { displayWidth, truncate } from "./text.ts";
13
+ import { accentStyle, onBackground, type Style, type Theme } from "./theme.ts";
14
+
15
+ export type ArrangeStripSection = Readonly<{
16
+ name: string;
17
+ /** 0-based first bar. */
18
+ startBar: number;
19
+ bars: number;
20
+ }>;
21
+
22
+ export type ArrangeStripView = Readonly<{
23
+ /** Bars on the timeline (the score's `bars`). */
24
+ bars: number;
25
+ sections: readonly ArrangeStripSection[];
26
+ /** The looped section's name, if any. */
27
+ loop?: string | undefined;
28
+ /** Playhead in bars (fractional), score time. */
29
+ playheadBar?: number | undefined;
30
+ /** `intro verse chorus×2`, shown at the right edge when it fits. */
31
+ form?: string | undefined;
32
+ }>;
33
+
34
+ /** One painted cell of the strip, for tests and narrow terminals. */
35
+ export type ArrangeStripCell = Readonly<{
36
+ ch: string;
37
+ /** Index into `view.sections`, or -1 for an unmarked bar. */
38
+ section: number;
39
+ playhead: boolean;
40
+ }>;
41
+
42
+ /**
43
+ * Lay the strip out over `width` cells: each cell covers `bars / width`
44
+ * bars and shows the section that starts latest at or before it (a later
45
+ * marker wins where sections overlap). Names start at a section's first
46
+ * cell and are cut at the next boundary.
47
+ */
48
+ export function arrangeStripCells(
49
+ view: ArrangeStripView,
50
+ width: number,
51
+ unicode = true,
52
+ ): ArrangeStripCell[] {
53
+ const cells: ArrangeStripCell[] = [];
54
+ const bars = Math.max(1, view.bars);
55
+ const span = Math.max(1, Math.floor(width));
56
+ const owner = (bar: number): number => {
57
+ let found = -1;
58
+ let start = -1;
59
+ view.sections.forEach((section, index) => {
60
+ if (
61
+ bar >= section.startBar &&
62
+ bar < section.startBar + section.bars &&
63
+ section.startBar >= start
64
+ ) {
65
+ found = index;
66
+ start = section.startBar;
67
+ }
68
+ });
69
+ return found;
70
+ };
71
+ const playCell =
72
+ view.playheadBar === undefined
73
+ ? -1
74
+ : Math.min(span - 1, Math.floor((view.playheadBar / bars) * span));
75
+ for (let x = 0; x < span; x++) {
76
+ const bar = Math.floor((x / span) * bars);
77
+ const section = owner(bar);
78
+ cells.push({
79
+ ch: section < 0 ? (unicode ? "─" : "-") : " ",
80
+ section,
81
+ playhead: x === playCell,
82
+ });
83
+ }
84
+ // Names: from each run's first cell; a run is consecutive cells of one
85
+ // section. A run too short for the name gets its first letters.
86
+ let x = 0;
87
+ while (x < span) {
88
+ const section = cells[x]!.section;
89
+ let end = x;
90
+ while (end < span && cells[end]!.section === section) end++;
91
+ if (section >= 0) {
92
+ const name = view.sections[section]!.name;
93
+ const room = end - x - (end - x > 2 ? 1 : 0);
94
+ const label = room > 0 ? truncate(name, room, unicode ? "…" : "~") : "";
95
+ // A boundary bar: one cell gap between neighbours when there is room.
96
+ let at = x + (end - x > 2 ? 1 : 0);
97
+ for (const ch of label) {
98
+ if (at >= end) break;
99
+ cells[at] = { ...cells[at]!, ch };
100
+ at += Math.max(1, displayWidth(ch));
101
+ }
102
+ if (end - x > 2) cells[x] = { ...cells[x]!, ch: unicode ? "▏" : "|" };
103
+ }
104
+ x = end;
105
+ }
106
+ return cells;
107
+ }
108
+
109
+ /** Plain text of the strip, for tests. */
110
+ export function arrangeStripText(
111
+ view: ArrangeStripView,
112
+ width: number,
113
+ unicode = true,
114
+ ): string {
115
+ return arrangeStripCells(view, width, unicode)
116
+ .map((cell) => (cell.playhead ? (unicode ? "▼" : "v") : cell.ch))
117
+ .join("");
118
+ }
119
+
120
+ export function paintArrangeStrip(
121
+ buffer: CellBuffer,
122
+ y: number,
123
+ width: number,
124
+ view: ArrangeStripView,
125
+ theme: Theme,
126
+ unicode: boolean,
127
+ ): void {
128
+ const roles = theme.roles;
129
+ buffer.fill(0, y, width, 1, roles.canvas);
130
+ const form = view.form ? `form ${view.form}` : "";
131
+ const formWidth = displayWidth(form);
132
+ // The form goes right when the timeline keeps at least 24 cells.
133
+ const showForm = form !== "" && width - formWidth - 3 >= 24;
134
+ const stripWidth = Math.max(1, width - 2 - (showForm ? formWidth + 2 : 0));
135
+ const cells = arrangeStripCells(view, stripWidth, unicode);
136
+ const playSection =
137
+ view.playheadBar === undefined
138
+ ? -1
139
+ : (cells.find((cell) => cell.playhead)?.section ?? -1);
140
+ cells.forEach((cell, index) => {
141
+ const x = 1 + index;
142
+ if (cell.section < 0) {
143
+ buffer.set(
144
+ x,
145
+ y,
146
+ cell.playhead ? (unicode ? "▼" : "v") : cell.ch,
147
+ cell.playhead ? { ...roles.hit, bold: true } : roles.faint,
148
+ );
149
+ return;
150
+ }
151
+ const section = view.sections[cell.section]!;
152
+ const accent = accentStyle(theme, section.name);
153
+ const looped = view.loop === section.name;
154
+ const style: Style = {
155
+ ...accent,
156
+ reverse: true,
157
+ bold: looped || cell.section === playSection,
158
+ ...(looped ? {} : { dim: cell.section !== playSection }),
159
+ };
160
+ buffer.set(
161
+ x,
162
+ y,
163
+ cell.playhead ? (unicode ? "▼" : "v") : cell.ch,
164
+ cell.playhead ? { ...roles.hit, reverse: true, bold: true } : style,
165
+ );
166
+ });
167
+ if (showForm)
168
+ buffer.text(
169
+ width - 1 - formWidth,
170
+ y,
171
+ form,
172
+ onBackground(roles.muted, roles.canvas),
173
+ );
174
+ }