@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/app.ts CHANGED
@@ -14,19 +14,31 @@
14
14
 
15
15
  import {
16
16
  ActivityFeed,
17
+ ERROR_FADE_MS,
18
+ fail,
17
19
  revisionLabel,
18
20
  spinnerFrame,
19
21
  type ActivityCard,
22
+ type Receipt,
20
23
  type TranscriptEntry,
21
24
  } from "./activity.ts";
22
25
  import {
23
26
  paintHighway,
27
+ projectionFor,
24
28
  resolveBeat,
25
29
  type TrackScoreSnapshot,
26
30
  } from "./highway.ts";
31
+ import { placeholderHint } from "./hints.ts";
32
+ import {
33
+ Delight,
34
+ downbeatGlint,
35
+ FIRST_LOOP_CARD,
36
+ sweepProgress,
37
+ } from "./delight.ts";
27
38
  import { asciiHint, fitHint, HINTS } from "./grammar.ts";
28
39
  import { GuideBrowser } from "./guide.ts";
29
40
  import { listGuides } from "../guides/index.ts";
41
+ import { topicMiss } from "../src/lang/glossary.ts";
30
42
  import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
31
43
  import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
32
44
  import {
@@ -70,11 +82,17 @@ export interface AppView {
70
82
  types?: TypesIndicator | undefined;
71
83
  /**
72
84
  * The line under the prompt: `$0.12 session · $0.48 today · opus-5.5 ·
73
- * gateway`, or `no model · dawg login`. The caller sizes it to the width.
85
+ * gateway`, or `commands only`. The caller sizes it to the width.
74
86
  */
75
87
  spend?: string | undefined;
76
- /** No agent provider: the placeholder teaches commands, no STEER pill. */
88
+ /** No agent provider: the placeholder teaches commands, no NOW pill. */
77
89
  agentOffline?: boolean | undefined;
90
+ /**
91
+ * Show-me: the command the agent is writing, as it streams (ghost text in
92
+ * an empty prompt bar), and the caption naming the human gesture.
93
+ */
94
+ showMe?:
95
+ { ghost?: string | undefined; caption?: string | undefined } | undefined;
78
96
  /** Play mode: replaces the header and adds the keyboard strip row. */
79
97
  play?: PlayHeaderView | undefined;
80
98
  /** Song master meter: integrated LUFS and true peak of the playing loop. */
@@ -136,6 +154,8 @@ export interface UiState {
136
154
  guide?: GuideBrowser | undefined;
137
155
  /** The fader drawer, docked over the bottom of the piano roll. */
138
156
  drawer?: DrawerView | undefined;
157
+ /** Downbeat glint, first-loop sweep and ghost-note lanes (delight.ts). */
158
+ delight?: Delight | undefined;
139
159
  }
140
160
 
141
161
  /**
@@ -412,18 +432,47 @@ function syncSegment(
412
432
  return { text: `${glyph[sync]} ${sync}`, style: style[sync], priority: 4 };
413
433
  }
414
434
 
435
+ /**
436
+ * The song header compressed for play mode's row: transport, tempo, the
437
+ * track and the revision (`▶ 120 BPM · drums · rev 1`).
438
+ */
439
+ export function playContext(
440
+ score: TrackScoreSnapshot,
441
+ unicode: boolean,
442
+ ): string {
443
+ const playing = score.playing === true;
444
+ const glyph = playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||";
445
+ const name = score.trackName ?? score.trackId ?? "track";
446
+ return [
447
+ `${glyph} ${score.bpm ?? 120} BPM`,
448
+ name,
449
+ ...(score.revision !== undefined ? [`rev ${score.revision}`] : []),
450
+ ].join(" · ");
451
+ }
452
+
415
453
  function paintHeader(
416
454
  buffer: CellBuffer,
417
455
  view: AppView,
418
456
  ui: UiState,
419
457
  width: number,
420
458
  hits?: HitMap,
459
+ beat = 0,
421
460
  ): void {
422
461
  const { theme, capabilities } = ui;
423
462
  const roles = theme.roles;
424
463
  const score = view.score;
425
464
  const unicode = capabilities.unicode;
426
465
  const playing = score.playing === true;
466
+ // The ▶ brightens for a moment on each bar's downbeat.
467
+ const glint = downbeatGlint({
468
+ playing,
469
+ beat,
470
+ bpm: score.bpm ?? 120,
471
+ beatsPerBar: score.beatsPerBar ?? 4,
472
+ barBeats: score.barBeats,
473
+ loopBeats: score.loopBeats,
474
+ reducedMotion: ui.reducedMotion,
475
+ });
427
476
  const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
428
477
  const name = score.trackName ?? score.trackId ?? "track";
429
478
  const left: Segment[] = [
@@ -436,7 +485,11 @@ function paintHeader(
436
485
  },
437
486
  {
438
487
  text: transport,
439
- style: playing ? roles.transport : roles.paused,
488
+ style: playing
489
+ ? glint
490
+ ? { ...shade(roles.transport, 0.55), bold: true }
491
+ : roles.transport
492
+ : roles.paused,
440
493
  priority: 0,
441
494
  target: { kind: "transport" },
442
495
  },
@@ -527,7 +580,9 @@ function cardMarker(card: ActivityCard, unicode: boolean): string {
527
580
  }
528
581
 
529
582
  export function cardText(card: ActivityCard, unicode: boolean): string {
530
- const parts = [`${cardMarker(card, unicode)} ${card.text}`];
583
+ const count =
584
+ card.count && card.count > 1 ? ` ${unicode ? "×" : "x"}${card.count}` : "";
585
+ const parts = [`${cardMarker(card, unicode)} ${card.text}${count}`];
531
586
  const revision = revisionLabel(
532
587
  card.baseRevision,
533
588
  card.resultRevision,
@@ -535,15 +590,46 @@ export function cardText(card: ActivityCard, unicode: boolean): string {
535
590
  );
536
591
  if (revision) parts.push(revision);
537
592
  if (card.hint) parts.push(card.hint);
593
+ if (card.suffix) parts.push(card.suffix);
538
594
  return parts.join(" · ");
539
595
  }
540
596
 
597
+ /** How long each one-time note leads when they cannot all fit at once. */
598
+ export const ONCE_ROTATE_MS = 3_000;
599
+
600
+ /**
601
+ * The cards for the strip's width: when the one-time notes cannot all show
602
+ * whole beside the receipts, they take turns (one every `ONCE_ROTATE_MS`,
603
+ * on the frame clock), so each is read in full at least once.
604
+ */
605
+ export function stripCards(
606
+ cards: readonly ActivityCard[],
607
+ room: number,
608
+ nowMs: number,
609
+ unicode: boolean,
610
+ ): ActivityCard[] {
611
+ const width = (list: readonly ActivityCard[]) =>
612
+ list.reduce(
613
+ (sum, card, index) =>
614
+ sum + (index > 0 ? 3 : 0) + displayWidth(cardText(card, unicode)),
615
+ 0,
616
+ );
617
+ if (width(cards) <= room) return [...cards];
618
+ const steady = cards.filter((card) => !card.once);
619
+ const once = cards.filter((card) => card.once);
620
+ if (once.length < 2) return [...cards];
621
+ const turn =
622
+ once[Math.floor(Math.max(0, nowMs) / ONCE_ROTATE_MS) % once.length]!;
623
+ return [...steady, turn];
624
+ }
625
+
541
626
  function paintActivity(
542
627
  buffer: CellBuffer,
543
628
  y: number,
544
629
  ui: UiState,
545
630
  width: number,
546
631
  nowMs: number,
632
+ agentOffline = false,
547
633
  ): void {
548
634
  const { theme, capabilities, activity } = ui;
549
635
  const roles = theme.roles;
@@ -567,9 +653,10 @@ function paintActivity(
567
653
  ...roles.agent,
568
654
  bold: true,
569
655
  });
570
- if (activity.streaming && x < limit - 4) {
656
+ // The agent's latest sentence, faint: what it is doing, in its words.
657
+ if (activity.streamSentence && x < limit - 4) {
571
658
  x += buffer.text(x, y, " · ", roles.faint);
572
- const tail = activity.streaming;
659
+ const tail = activity.streamSentence;
573
660
  const room = Math.max(0, limit - x);
574
661
  const shown =
575
662
  displayWidth(tail) > room
@@ -577,13 +664,24 @@ function paintActivity(
577
664
  .slice(-(room - 1))
578
665
  .join("")}`
579
666
  : tail;
580
- x += buffer.text(x, y, truncate(shown, room), roles.muted);
667
+ x += buffer.text(x, y, truncate(shown, room), roles.faint);
581
668
  }
582
669
  } else {
583
- const cards = [...activity.cards].reverse();
670
+ const cards = stripCards(
671
+ activity.visible(nowMs),
672
+ Math.max(0, limit - x),
673
+ nowMs,
674
+ capabilities.unicode,
675
+ );
584
676
  cards.forEach((card, index) => {
585
677
  if (x >= limit) return;
586
678
  const text = cardText(card, capabilities.unicode);
679
+ // A one-time note behind the first slot shows whole or not at all
680
+ // (it stays in ctrl-o); a cut note loses its key word.
681
+ if (index > 0 && card.once && x + 3 + displayWidth(text) > limit) {
682
+ x = limit;
683
+ return;
684
+ }
587
685
  if (index > 0) {
588
686
  if (x + 3 + Math.min(12, displayWidth(text)) > limit) {
589
687
  x = limit;
@@ -602,7 +700,14 @@ function paintActivity(
602
700
  : card.tone === "success"
603
701
  ? roles.success
604
702
  : roles.text;
605
- if (index > 0) style = card.tone === "error" ? roles.error : roles.faint;
703
+ // Behind the first slot everything is faint; an error keeps its
704
+ // colour until it is old, then fades with the rest.
705
+ if (index > 0)
706
+ style =
707
+ card.tone === "error" && age < ERROR_FADE_MS
708
+ ? roles.error
709
+ : roles.faint;
710
+ else if (card.once) style = roles.faint;
606
711
  else if (!ui.reducedMotion && age >= 0 && age < CARD_GLOW_MS)
607
712
  style = { ...shade(style, 0.4 * (1 - age / CARD_GLOW_MS)), bold: true };
608
713
  x += buffer.text(x, y, truncate(text, Math.max(0, limit - x)), style);
@@ -612,7 +717,9 @@ function paintActivity(
612
717
  x,
613
718
  y,
614
719
  truncate(
615
- "type a request · space plays on an empty prompt · /help",
720
+ agentOffline
721
+ ? "type a command · space plays on an empty prompt · /help"
722
+ : "type a request · space plays on an empty prompt · /help",
616
723
  limit - x,
617
724
  ),
618
725
  roles.faint,
@@ -626,7 +733,7 @@ function paintActivity(
626
733
  // Prompt panel
627
734
 
628
735
  function pill(mode: PromptMode): string {
629
- return mode === "queue" ? " QUEUE " : " STEER ";
736
+ return mode === "queue" ? " NEXT " : " NOW ";
630
737
  }
631
738
 
632
739
  function paintPrompt(
@@ -698,12 +805,27 @@ function paintPrompt(
698
805
  if (index === rows - 1 && layoutInfo.first + rows < layoutInfo.total)
699
806
  buffer.text(width - 3, y, capabilities.unicode ? "↓" : "v", faint);
700
807
  }
701
- if (prompt.value.length === 0) {
702
- const placeholder = view.agentOffline
703
- ? "try: tempo 96 · add C4 at 0 · /help (dawg login enables the agent)"
704
- : mode === "queue"
705
- ? "queue a request for after the current one…"
706
- : "describe a change — “add a walking bass in A minor”";
808
+ const ghost = view.showMe?.ghost;
809
+ const caption = view.showMe?.caption;
810
+ if (prompt.value.length === 0 && (ghost || caption)) {
811
+ // The agent's command as it streams, then the gesture it stands for.
812
+ const typed = ghost ? `${ghost}${capabilities.unicode ? "▏" : "_"}` : "";
813
+ const line = typed && caption ? `${typed} ${caption}` : typed || caption!;
814
+ buffer.text(5, top + 1, truncate(line, editorWidth - 1), faint);
815
+ } else if (prompt.value.length === 0) {
816
+ // Seeded per session (tui/hints.ts): the words stay put while you
817
+ // work and change between sessions, chosen by agent and song state.
818
+ const score = view.score;
819
+ const placeholder = placeholderHint({
820
+ seed: score.sessionId ?? "dawg",
821
+ agent: !view.agentOffline,
822
+ filled:
823
+ score.notes.length > 0 ||
824
+ (score.clips ?? []).length > 0 ||
825
+ (score.layers ?? []).some((layer) => layer.notes.length > 0),
826
+ drums: projectionFor(score).kind !== "pitch",
827
+ queue: mode === "queue",
828
+ });
707
829
  buffer.text(5, top + 1, truncate(placeholder, editorWidth - 1), faint);
708
830
  }
709
831
 
@@ -714,13 +836,14 @@ function paintPrompt(
714
836
  for (let column = 1; column < width - 1; column += 1)
715
837
  buffer.set(column, y, box.h, border);
716
838
  buffer.set(width - 1, y, box.br, border);
839
+ // One notation everywhere: ctrl-<key>, as /help and the menu spell it.
717
840
  const hints =
718
- width >= 100
719
- ? " enter send · shift+enter newline · ^q queue · ^z undo · ^o log · ^c quit "
720
- : width >= 72
721
- ? " enter send · ^j newline · ^q queue · ^z undo · ^o log "
722
- : width >= 44
723
- ? " enter · ^q queue · ^z undo · ^o log "
841
+ width >= 112
842
+ ? " enter send · shift-enter newline · ctrl-q now/next · ctrl-z undo · ctrl-o log · ctrl-c quit "
843
+ : width >= 80
844
+ ? " enter send · ctrl-j newline · ctrl-q now/next · ctrl-z undo · ctrl-o log "
845
+ : width >= 52
846
+ ? " enter · ctrl-q now/next · ctrl-z undo · ctrl-o log "
724
847
  : "";
725
848
  const spend = view.spend ? ` ${view.spend} ` : "";
726
849
  const spendWidth = displayWidth(spend);
@@ -1164,7 +1287,10 @@ export function composeFrame(
1164
1287
  buffer,
1165
1288
  layout.header,
1166
1289
  width,
1167
- view.play,
1290
+ {
1291
+ ...view.play,
1292
+ context: playContext(view.score, ui.capabilities.unicode),
1293
+ },
1168
1294
  ui.theme,
1169
1295
  ui.capabilities.unicode,
1170
1296
  );
@@ -1188,7 +1314,15 @@ export function composeFrame(
1188
1314
  height: layout.highway.height - 1,
1189
1315
  };
1190
1316
  }
1191
- } else paintHeader(buffer, view, ui, width, hits);
1317
+ } else
1318
+ paintHeader(
1319
+ buffer,
1320
+ view,
1321
+ ui,
1322
+ width,
1323
+ hits,
1324
+ view.beat ?? resolveBeat(view.score, nowMs),
1325
+ );
1192
1326
  if (view.arrange && layout.highway.height > 4) {
1193
1327
  paintArrangeStrip(
1194
1328
  buffer,
@@ -1228,6 +1362,16 @@ export function composeFrame(
1228
1362
  theme: ui.theme,
1229
1363
  capabilities: ui.capabilities,
1230
1364
  reducedMotion: ui.reducedMotion,
1365
+ hint: {
1366
+ seed: view.score.sessionId ?? "dawg",
1367
+ agent: !view.agentOffline,
1368
+ },
1369
+ sweep: sweepProgress(
1370
+ ui.delight?.sweepStartedAtMs,
1371
+ nowMs,
1372
+ ui.reducedMotion,
1373
+ ),
1374
+ glows: ui.delight?.glows(nowMs, ui.reducedMotion),
1231
1375
  },
1232
1376
  );
1233
1377
  if (ui.drawer)
@@ -1236,7 +1380,11 @@ export function composeFrame(
1236
1380
  layout.highway,
1237
1381
  width,
1238
1382
  ui.drawer,
1239
- { theme: ui.theme, unicode: ui.capabilities.unicode },
1383
+ {
1384
+ theme: ui.theme,
1385
+ unicode: ui.capabilities.unicode,
1386
+ reducedMotion: ui.reducedMotion,
1387
+ },
1240
1388
  hits,
1241
1389
  );
1242
1390
  }
@@ -1245,7 +1393,14 @@ export function composeFrame(
1245
1393
  text: ui.keys,
1246
1394
  hint: HINTS.keys,
1247
1395
  });
1248
- paintActivity(buffer, layout.activity, ui, width, nowMs);
1396
+ paintActivity(
1397
+ buffer,
1398
+ layout.activity,
1399
+ ui,
1400
+ width,
1401
+ nowMs,
1402
+ view.agentOffline ?? false,
1403
+ );
1249
1404
  const prompt = paintPrompt(buffer, view, ui, layout, width);
1250
1405
  return {
1251
1406
  buffer,
@@ -1260,8 +1415,15 @@ export function composeFrame(
1260
1415
  // ---------------------------------------------------------------------------
1261
1416
  // Mutable app
1262
1417
 
1418
+ /** The longest a lagging terminal waits between frames. */
1419
+ export const MAX_BACKOFF_MS = 250;
1420
+
1263
1421
  export interface TerminalIO {
1264
- write(data: string): void;
1422
+ /**
1423
+ * Writes a frame. `false` means the terminal is not keeping up (the
1424
+ * stream's buffer is full): frames back off until a write is accepted.
1425
+ */
1426
+ write(data: string): void | boolean;
1265
1427
  columns(): number;
1266
1428
  rows(): number;
1267
1429
  }
@@ -1316,6 +1478,15 @@ export class TuiApp {
1316
1478
  private lastFrameAt = Number.NEGATIVE_INFINITY;
1317
1479
  /** Minimum interval between frames (~30 fps). */
1318
1480
  frameIntervalMs = 33;
1481
+ /**
1482
+ * Extra spacing while the terminal lags: doubles on each refused write up
1483
+ * to `MAX_BACKOFF_MS`, resets on the first accepted one.
1484
+ */
1485
+ backoffMs = 0;
1486
+ /** Session-only delight; `heardLoop` comes from the session's metadata. */
1487
+ readonly delight = new Delight();
1488
+ /** The song wrapped for the first time: the host records it in meta. */
1489
+ onFirstLoop: (() => void) | undefined;
1319
1490
 
1320
1491
  constructor(options: TuiAppOptions) {
1321
1492
  this.io = options.io;
@@ -1348,6 +1519,7 @@ export class TuiApp {
1348
1519
  keys: this.keys,
1349
1520
  guide: this.guide,
1350
1521
  drawer: this.drawer,
1522
+ delight: this.delight,
1351
1523
  };
1352
1524
  }
1353
1525
 
@@ -1367,9 +1539,13 @@ export class TuiApp {
1367
1539
  */
1368
1540
  render(view: AppView, options: { force?: boolean } = {}): string {
1369
1541
  const now = this.clock();
1370
- if (!options.force && now - this.lastFrameAt < this.frameIntervalMs)
1542
+ if (
1543
+ !options.force &&
1544
+ now - this.lastFrameAt < this.frameIntervalMs + this.backoffMs
1545
+ )
1371
1546
  return "";
1372
1547
  this.lastFrameAt = now;
1548
+ this.watchFirstLoop(view, now);
1373
1549
  const frame = composeFrame(
1374
1550
  view,
1375
1551
  this.ui,
@@ -1378,10 +1554,43 @@ export class TuiApp {
1378
1554
  );
1379
1555
  this.lastFrame = frame;
1380
1556
  const out = this.writer.frame(frame.buffer, frame.cursor);
1381
- if (out) this.io.write(out);
1557
+ if (out) {
1558
+ const accepted = this.io.write(out);
1559
+ this.backoffMs =
1560
+ accepted === false
1561
+ ? Math.min(
1562
+ MAX_BACKOFF_MS,
1563
+ Math.max(this.frameIntervalMs, this.backoffMs * 2),
1564
+ )
1565
+ : 0;
1566
+ }
1382
1567
  return out;
1383
1568
  }
1384
1569
 
1570
+ /**
1571
+ * The first wrap of a song that never wrapped before: a one-shot sweep
1572
+ * and the card `↻ first loop`. The card shows with motion off too (it is
1573
+ * information); the sweep does not.
1574
+ */
1575
+ private watchFirstLoop(view: AppView, now: number): void {
1576
+ const score = view.score;
1577
+ const wrapped = this.delight.observe(
1578
+ {
1579
+ playing: score.playing === true,
1580
+ beat: view.beat ?? resolveBeat(score, now),
1581
+ loopBeats: score.loopBeats,
1582
+ empty:
1583
+ score.notes.length === 0 &&
1584
+ (score.clips ?? []).length === 0 &&
1585
+ !(score.layers ?? []).some((layer) => layer.notes.length > 0),
1586
+ },
1587
+ now,
1588
+ );
1589
+ if (!wrapped) return;
1590
+ this.activity.pushCard(FIRST_LOOP_CARD, { tone: "info", once: true });
1591
+ this.onFirstLoop?.();
1592
+ }
1593
+
1385
1594
  /** Decode one key sequence (from TerminalInputDecoder) into an input. */
1386
1595
  input(value: string | { type: "paste"; text: string }): AppInput {
1387
1596
  if (typeof value !== "string")
@@ -1485,7 +1694,7 @@ export class TuiApp {
1485
1694
  }
1486
1695
  if (this.overlay === "text" && this.text) {
1487
1696
  const nav = overlayKey(value);
1488
- const page = Math.max(1, this.io.rows() - 10);
1697
+ const page = this.overlayRows();
1489
1698
  if (nav && nav !== "enter") {
1490
1699
  const step =
1491
1700
  nav === "down"
@@ -1497,19 +1706,22 @@ export class TuiApp {
1497
1706
  : nav === "pgup"
1498
1707
  ? -page
1499
1708
  : 0;
1500
- const total = this.text.lines.length;
1709
+ // Clamp to the last full page, the same bound paintText uses, so
1710
+ // End then ↑ moves the view on the first press.
1711
+ const max = Math.max(0, this.text.lines.length - page);
1712
+ const from = Math.min(max, this.text.scroll);
1501
1713
  this.text.scroll =
1502
1714
  nav === "home"
1503
1715
  ? 0
1504
1716
  : nav === "end"
1505
- ? total
1506
- : Math.max(0, Math.min(total, this.text.scroll + step));
1717
+ ? max
1718
+ : Math.max(0, Math.min(max, from + step));
1507
1719
  return { type: "overlay" };
1508
1720
  }
1509
1721
  }
1510
1722
  if (this.overlay === "log") {
1511
1723
  const nav = overlayKey(value);
1512
- const page = Math.max(1, this.io.rows() - 10);
1724
+ const page = this.overlayRows();
1513
1725
  if (nav && nav !== "enter") {
1514
1726
  const step =
1515
1727
  nav === "up"
@@ -1525,12 +1737,15 @@ export class TuiApp {
1525
1737
  this.activity.transcript,
1526
1738
  this.log.filter,
1527
1739
  ).length;
1740
+ // Scroll counts rows up from the newest; paintOverlay's bound.
1741
+ const max = Math.max(0, total - page);
1742
+ const from = Math.min(max, this.log.scroll);
1528
1743
  this.log.scroll =
1529
1744
  nav === "home"
1530
- ? total
1745
+ ? max
1531
1746
  : nav === "end"
1532
1747
  ? 0
1533
- : Math.max(0, Math.min(total, this.log.scroll + step));
1748
+ : Math.max(0, Math.min(max, from + step));
1534
1749
  return { type: "overlay" };
1535
1750
  }
1536
1751
  if (key.type === "text" && key.text === "/") {
@@ -1624,6 +1839,19 @@ export class TuiApp {
1624
1839
  if (this.overlay === "picker") this.overlay = undefined;
1625
1840
  }
1626
1841
 
1842
+ /**
1843
+ * Rows of text a panel over the highway shows (its height less the two
1844
+ * borders), as last rendered; a guess from the terminal height before the
1845
+ * first frame.
1846
+ */
1847
+ private overlayRows(): number {
1848
+ const highway = this.lastFrame?.layout.highway.height;
1849
+ return Math.max(
1850
+ 1,
1851
+ highway !== undefined && highway > 2 ? highway - 2 : this.io.rows() - 10,
1852
+ );
1853
+ }
1854
+
1627
1855
  /** Show static lines over the highway (`/help`, lists); replaces any overlay. */
1628
1856
  openText(title: string, lines: readonly string[]): void {
1629
1857
  this.text = { title, lines: lines.slice(0, 512), scroll: 0 };
@@ -1681,9 +1909,10 @@ export class TuiApp {
1681
1909
 
1682
1910
  /**
1683
1911
  * Handle TUI-local slash commands (`/log`, `/theme`, `/motion`, `/guide`).
1684
- * Returns a receipt, or undefined when the command is not a UI command.
1912
+ * Returns a receipt (a structured one for a refusal), or undefined when
1913
+ * the command is not a UI command.
1685
1914
  */
1686
- command(text: string): string | undefined {
1915
+ command(text: string): string | Receipt | undefined {
1687
1916
  const command = text.trim();
1688
1917
  if (/^\/(log|transcript)$/i.test(command)) {
1689
1918
  this.overlay = this.overlay === "log" ? undefined : "log";
@@ -1708,7 +1937,12 @@ export class TuiApp {
1708
1937
  if (guide) {
1709
1938
  const topic = guide[1]?.trim();
1710
1939
  if (!this.openGuide(topic))
1711
- return `no guide named ${topic} · /guide lists them all`;
1940
+ return fail(
1941
+ topicMiss(
1942
+ topic!,
1943
+ (this.guide?.guides ?? listGuides()).map((g) => g.id),
1944
+ ),
1945
+ );
1712
1946
  return topic
1713
1947
  ? `guide · ${this.guide?.guides.find((g) => g.id === this.guide?.page)?.title ?? topic} · esc back`
1714
1948
  : "guides · → open · esc closes";