@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/highway.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * lane per voice with a legend.
11
11
  */
12
12
 
13
+ import { emptyHint } from "./hints.ts";
13
14
  import { CellBuffer } from "./screen.ts";
14
15
  import { truncate } from "./text.ts";
15
16
  import {
@@ -44,6 +45,24 @@ export interface NoteSnapshot {
44
45
  * from (`D` for a `D−47` tag), since the lane is the key, not the sound.
45
46
  */
46
47
  centsFrom?: string | undefined;
48
+ /** Sung syllable (`Note.lyric`), drawn beside the head like the cents tag. */
49
+ lyric?: string | undefined;
50
+ }
51
+
52
+ /** An audio clip on the focused track (0.7), drawn in the clip row. */
53
+ export interface ClipSnapshot {
54
+ id: string;
55
+ /** Song beats where the clip sounds. */
56
+ startBeat: number;
57
+ durationBeats: number;
58
+ /** Normalised RMS over the clip's window (0..1), oldest first. */
59
+ peaks?: readonly number[] | undefined;
60
+ /** Take name and clip text, shown after the id. */
61
+ take?: string | undefined;
62
+ text?: string | undefined;
63
+ muted?: boolean | undefined;
64
+ /** The clip starts where another ends: a comp seam. */
65
+ seam?: boolean | undefined;
47
66
  }
48
67
 
49
68
  export interface TrackScoreSnapshot {
@@ -83,6 +102,49 @@ export interface TrackScoreSnapshot {
83
102
  * accent. Empty or omitted in focus view.
84
103
  */
85
104
  layers?: readonly HighwayLayer[] | undefined;
105
+ /** The focused track sings (instrument `vocal`): the empty hint says how. */
106
+ vocal?: boolean | undefined;
107
+ /** Audio clips on the focused track (0.7): the right-edge clip row. */
108
+ clips?: readonly ClipSnapshot[] | undefined;
109
+ /**
110
+ * Detected pitch of the focused track's audio (0.7 `/vocal pitch trace`),
111
+ * sorted by beat. Drawn as a dotted line under the notes, in the warning
112
+ * role where it sits more than 15 cents off the nearest semitone; hidden
113
+ * while one row spans more than a beat.
114
+ */
115
+ pitchTrace?: readonly PitchTracePoint[] | undefined;
116
+ }
117
+
118
+ /** One point of a pitch trace: score beats and fractional MIDI pitch. */
119
+ export interface PitchTracePoint {
120
+ beat: number;
121
+ pitch: number;
122
+ }
123
+
124
+ /** Cents a trace point sits away from its nearest semitone before it warns. */
125
+ export const TRACE_WARN_CENTS = 15;
126
+
127
+ /** The trace point nearest `beat` within `half` beats, by binary search. */
128
+ export function tracePointAt(
129
+ trace: readonly PitchTracePoint[],
130
+ beat: number,
131
+ half: number,
132
+ ): PitchTracePoint | undefined {
133
+ let lo = 0;
134
+ let hi = trace.length;
135
+ while (lo < hi) {
136
+ const mid = (lo + hi) >> 1;
137
+ if (trace[mid]!.beat < beat) lo = mid + 1;
138
+ else hi = mid;
139
+ }
140
+ let best: PitchTracePoint | undefined;
141
+ for (const i of [lo - 1, lo]) {
142
+ const point = trace[i];
143
+ if (!point || Math.abs(point.beat - beat) > half) continue;
144
+ if (!best || Math.abs(point.beat - beat) < Math.abs(best.beat - beat))
145
+ best = point;
146
+ }
147
+ return best;
86
148
  }
87
149
 
88
150
  /** One background track overlaid on the highway. */
@@ -413,6 +475,12 @@ export interface HighwayOptions {
413
475
  lookaheadBeats?: number;
414
476
  /** Background style painted under the highway. */
415
477
  background?: Style;
478
+ /** Seed and agent presence for the empty-state hint (tui/hints.ts). */
479
+ hint?: Readonly<{ seed: string; agent: boolean }> | undefined;
480
+ /** The first-loop sweep's head, 0..1 across the hit line (tui/delight.ts). */
481
+ sweep?: number | undefined;
482
+ /** Play keys sounding without recording: their lanes glow on the hit line. */
483
+ glows?: readonly Readonly<{ pitch: number; strength: number }>[] | undefined;
416
484
  }
417
485
 
418
486
  export interface HighwayLayout {
@@ -473,6 +541,7 @@ interface Painter {
473
541
  put(x: number, y: number, ch: string, style: Style): void;
474
542
  restyle(x: number, y: number, style: Style): void;
475
543
  isBlank(x: number, y: number): boolean;
544
+ charAt(x: number, y: number): string | undefined;
476
545
  }
477
546
 
478
547
  /**
@@ -516,6 +585,9 @@ export function paintHighway(
516
585
  isBlank(x, y) {
517
586
  return buffer.get(region.x + x, region.y + y)?.ch === " ";
518
587
  },
588
+ charAt(x, y) {
589
+ return buffer.get(region.x + x, region.y + y)?.ch;
590
+ },
519
591
  };
520
592
 
521
593
  const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
@@ -525,13 +597,17 @@ export function paintHighway(
525
597
  // centred hint says how to start. The hit line and grid still draw.
526
598
  const empty =
527
599
  score.notes.length === 0 &&
600
+ (score.clips ?? []).length === 0 &&
528
601
  !(score.layers ?? []).some((layer) => layer.notes.length > 0);
529
602
 
530
603
  // Beat, bar, and loop rules of increasing strength.
531
604
  const barBeats =
532
605
  score.barBeats && score.barBeats.length > 0 ? score.barBeats : undefined;
606
+ // An empty highway reserves one row for its hint: no rule scrolls
607
+ // through it, so the words never sit on the ruler, playing or paused.
608
+ const hintRow = empty ? Math.max(0, Math.floor(hitRow / 2)) : undefined;
533
609
  for (let row = 0; row <= lastNoteRow; row += 1) {
534
- if (row === hitRow) continue;
610
+ if (row === hitRow || row === hintRow) continue;
535
611
  const center = rowBeat(row);
536
612
  const half = 0.5 / rowsPerBeat;
537
613
  let k = Math.ceil(center - half - 1e-9);
@@ -613,37 +689,40 @@ export function paintHighway(
613
689
  );
614
690
  }
615
691
 
616
- // Loop-wrap sweep: a bright band crosses the hit line during the first beat
617
- // after the transport wraps.
618
- if (loop && !reduced && score.playing && beat >= loop * 0.5) {
619
- const loopPhase = ((beat % loop) + loop) % loop;
620
- if (loopPhase < 1) {
621
- const head = Math.round(gutter + loopPhase * areaWidth);
622
- for (let offset = 0; offset < 6; offset += 1) {
623
- const column = head - offset;
624
- if (column < gutter || column >= region.width) continue;
625
- painter.put(
626
- column,
627
- hitRow,
628
- glyphs.loopRule,
629
- shade(roles.loopRule, 0.5 - offset * 0.1),
630
- );
631
- }
692
+ // First-loop sweep: once per song, a faint band crosses the hit line
693
+ // when the loop first wraps (tui/delight.ts decides when).
694
+ if (options.sweep !== undefined && !reduced) {
695
+ const head = Math.round(gutter + options.sweep * areaWidth);
696
+ for (let offset = 0; offset < 6; offset += 1) {
697
+ const column = head - offset;
698
+ if (column < gutter || column >= region.width) continue;
699
+ painter.put(
700
+ column,
701
+ hitRow,
702
+ glyphs.loopRule,
703
+ shade(roles.loopRule, 0.5 - offset * 0.1),
704
+ );
632
705
  }
633
706
  }
634
707
 
635
708
  if (empty) {
636
709
  const name = score.trackName ?? score.trackId ?? "track";
637
- const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
638
- // First run: name the three ways in (ask, play, menu); narrow
639
- // windows fall back to one prompt command.
640
- const full = `${name} · empty · type a request · ctrl-p play · ctrl-k menu`;
641
- const text = truncate(
642
- full.length <= areaWidth ? full : `${name} · empty · ${start} to start`,
710
+ // One seeded line names a way in that works in this session: a
711
+ // request with an agent, a command without one (tui/hints.ts).
712
+ const text = emptyHint(
713
+ {
714
+ seed: options.hint?.seed ?? score.sessionId ?? "dawg",
715
+ agent: options.hint?.agent ?? true,
716
+ filled: false,
717
+ drums: projection.kind !== "pitch",
718
+ vocal: score.vocal === true,
719
+ playing: score.playing === true,
720
+ },
721
+ name,
643
722
  areaWidth,
644
723
  );
645
724
  const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
646
- const y = Math.max(0, Math.floor(hitRow / 2));
725
+ const y = hintRow ?? 0;
647
726
  for (let index = 0; index < text.length; index += 1)
648
727
  painter.put(x + index, y, text[index]!, roles.muted);
649
728
  }
@@ -668,6 +747,34 @@ export function paintHighway(
668
747
  }
669
748
  }
670
749
 
750
+ // 0.7 pitch trace: one dot per row under the notes, at the fractional
751
+ // lane of the detected pitch. Rows wider than a beat hide it.
752
+ const trace = score.pitchTrace;
753
+ if (trace && trace.length > 0 && rowsPerBeat >= 1) {
754
+ const half = 0.5 / rowsPerBeat;
755
+ const width = Math.max(1, layout.laneWidth || 1);
756
+ for (let row = 0; row <= lastNoteRow; row += 1) {
757
+ if (row === hitRow) continue;
758
+ let at = rowBeat(row);
759
+ if (loop) at = ((at % loop) + loop) % loop;
760
+ const point = tracePointAt(trace, at, half);
761
+ if (!point) continue;
762
+ const near = Math.round(point.pitch);
763
+ const lane = projection.laneOf({ startBeat: point.beat, pitch: near });
764
+ if (lane === undefined) continue;
765
+ const off = point.pitch - near;
766
+ const x =
767
+ laneX(layout, lane, areaWidth) +
768
+ Math.max(0, Math.min(width - 1, Math.floor((off + 0.5) * width)));
769
+ painter.put(
770
+ x,
771
+ row,
772
+ glyphs.muted,
773
+ Math.abs(off) * 100 > TRACE_WARN_CENTS ? roles.warning : roles.muted,
774
+ );
775
+ }
776
+ }
777
+
671
778
  // Note occurrences across loop repeats, ordered so active notes draw last.
672
779
  const belowBeats = (lastNoteRow - hitRow + 1) / rowsPerBeat;
673
780
  const aheadBeats = hitRow / rowsPerBeat + 1 / rowsPerBeat;
@@ -835,11 +942,13 @@ export function paintHighway(
835
942
  : shade(base, 0.45 * glow);
836
943
  if (headRow <= hitRow - 1) {
837
944
  tile(headRow, headGlyph, style);
838
- const tag =
945
+ const cents =
839
946
  (note.cents !== undefined || note.centsFrom !== undefined) &&
840
947
  !note.muted
841
948
  ? centsTag(note.cents, note.centsFrom)
842
949
  : "";
950
+ const lyric = note.lyric && !note.muted ? note.lyric.slice(0, 8) : "";
951
+ const tag = [cents, lyric].filter(Boolean).join(" ");
843
952
  if (tag && x + width + tag.length <= region.width)
844
953
  for (let index = 0; index < tag.length; index += 1)
845
954
  painter.put(x + width + index, headRow, tag[index]!, roles.muted);
@@ -938,9 +1047,144 @@ export function paintHighway(
938
1047
  }
939
1048
  }
940
1049
  }
1050
+ // Ghost notes: a play key that is not recording lights its lane on the
1051
+ // hit line for a moment, so the hand sees where the music would land.
1052
+ if (options.glows && !reduced) {
1053
+ const width = Math.max(1, layout.laneWidth || 1);
1054
+ for (const glow of options.glows) {
1055
+ const lane = projection.laneOf({ startBeat: beat, pitch: glow.pitch });
1056
+ if (lane === undefined) continue;
1057
+ const x = laneX(layout, lane, areaWidth);
1058
+ const style = {
1059
+ ...shade(accent, 0.45 * glow.strength),
1060
+ bold: glow.strength > 0.5,
1061
+ };
1062
+ for (let offset = 0; offset < width; offset += 1)
1063
+ painter.put(x + offset, hitRow, glyphs.hitStrong, style);
1064
+ }
1065
+ }
1066
+ if (score.clips && score.clips.length > 0 && region.width >= 24)
1067
+ paintClipRow(painter, score, {
1068
+ beat,
1069
+ hitRow,
1070
+ lastNoteRow,
1071
+ rowsPerBeat,
1072
+ width: region.width,
1073
+ loop,
1074
+ glyphs,
1075
+ accent,
1076
+ roles,
1077
+ });
941
1078
  return layout;
942
1079
  }
943
1080
 
1081
+ function rowHasOnlyRule(
1082
+ painter: Painter,
1083
+ x: number,
1084
+ length: number,
1085
+ row: number,
1086
+ glyphs: HighwayGlyphs,
1087
+ ): boolean {
1088
+ for (let index = 0; index < length; index += 1) {
1089
+ const ch = painter.charAt(x + index, row);
1090
+ if (ch !== " " && ch !== glyphs.beatRule) return false;
1091
+ }
1092
+ return true;
1093
+ }
1094
+
1095
+ /** Shading for a clip's level, quiet to loud. */
1096
+ const CLIP_LEVELS_UNICODE = ["▏", "░", "▒", "▓", "█"];
1097
+ const CLIP_LEVELS_ASCII = [":", ".", "o", "O", "#"];
1098
+
1099
+ /**
1100
+ * The clip row: the rightmost column carries each clip's level as it
1101
+ * falls toward the hit line, a seam (`┄`) where one clip continues
1102
+ * another, and `id take · text` beside the clip's first row.
1103
+ */
1104
+ function paintClipRow(
1105
+ painter: Painter,
1106
+ score: TrackScoreSnapshot,
1107
+ at: {
1108
+ beat: number;
1109
+ hitRow: number;
1110
+ lastNoteRow: number;
1111
+ rowsPerBeat: number;
1112
+ width: number;
1113
+ loop: number | undefined;
1114
+ glyphs: HighwayGlyphs;
1115
+ accent: Style;
1116
+ roles: Theme["roles"];
1117
+ },
1118
+ ): void {
1119
+ const column = at.width - 1;
1120
+ const levels =
1121
+ at.glyphs === ASCII_GLYPHS ? CLIP_LEVELS_ASCII : CLIP_LEVELS_UNICODE;
1122
+ const seamGlyph = at.glyphs === ASCII_GLYPHS ? "-" : "┄";
1123
+ const rowBeat = (row: number) => at.beat + (at.hitRow - row) / at.rowsPerBeat;
1124
+ const half = 0.5 / at.rowsPerBeat;
1125
+ const occurrences = (clip: ClipSnapshot): number[] => {
1126
+ if (!at.loop) return [clip.startBeat];
1127
+ const base = Math.floor(at.beat / at.loop) * at.loop;
1128
+ return [base - at.loop, base, base + at.loop].map(
1129
+ (offset) => offset + clip.startBeat,
1130
+ );
1131
+ };
1132
+ for (const clip of score.clips ?? []) {
1133
+ const style = clip.muted ? at.roles.mutedNote : at.accent;
1134
+ for (const start of occurrences(clip)) {
1135
+ const end = start + clip.durationBeats;
1136
+ for (let row = 0; row <= at.lastNoteRow; row += 1) {
1137
+ if (row === at.hitRow) continue;
1138
+ const center = rowBeat(row);
1139
+ if (center + half <= start || center - half >= end) continue;
1140
+ const isStart = start >= center - half && start < center + half;
1141
+ if (isStart && clip.seam) {
1142
+ painter.put(column, row, seamGlyph, at.roles.muted);
1143
+ } else {
1144
+ const peaks = clip.peaks;
1145
+ const fraction = Math.max(
1146
+ 0,
1147
+ Math.min(
1148
+ 0.999,
1149
+ (center - start) / Math.max(1e-9, clip.durationBeats),
1150
+ ),
1151
+ );
1152
+ const level =
1153
+ peaks && peaks.length > 0
1154
+ ? peaks[Math.floor(fraction * peaks.length)]!
1155
+ : 0.6;
1156
+ const index = clip.muted
1157
+ ? 0
1158
+ : Math.min(
1159
+ levels.length - 1,
1160
+ Math.round(level * (levels.length - 1)),
1161
+ );
1162
+ painter.put(column, row, levels[index]!, style);
1163
+ }
1164
+ if (isStart && row < at.hitRow) {
1165
+ const label = [clip.id, clip.take, clip.text ? `· ${clip.text}` : ""]
1166
+ .filter(Boolean)
1167
+ .join(" ")
1168
+ .slice(0, Math.max(0, Math.min(24, at.width - 6)));
1169
+ const x = column - 1 - label.length;
1170
+ // Over a beat rule the whole label draws; over notes it never does.
1171
+ const ruled = rowHasOnlyRule(
1172
+ painter,
1173
+ x,
1174
+ label.length,
1175
+ row,
1176
+ at.glyphs,
1177
+ );
1178
+ if (x > 3)
1179
+ for (let index = 0; index < label.length; index += 1)
1180
+ if (painter.isBlank(x + index, row) || ruled)
1181
+ painter.put(x + index, row, label[index]!, at.roles.muted);
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+ }
1187
+
944
1188
  /**
945
1189
  * A background track: a quiet head and sustain line in the track's accent,
946
1190
  * no hit flash, sparks or ghosts, so the focused track stays readable.
package/tui/hints.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Seeded hints: the empty-highway line and the prompt placeholder.
3
+ *
4
+ * Each list is chosen by what the session can do (an agent or commands
5
+ * only) and what the song holds (empty, pitched, drums), and one entry is
6
+ * picked by hashing the session id, so a session keeps the same words
7
+ * while it runs and the next session reads a different, equally short one.
8
+ * Every example is original and every command in it runs as typed.
9
+ */
10
+
11
+ export type HintState = Readonly<{
12
+ /** The session id (or any stable string): the seed. */
13
+ seed: string;
14
+ /** An agent provider is ready, so prose requests work. */
15
+ agent: boolean;
16
+ /** The song has notes, clips or layers. */
17
+ filled: boolean;
18
+ /** The focused track is a drum kit. */
19
+ drums?: boolean;
20
+ /** The focused track is a vocal track. */
21
+ vocal?: boolean;
22
+ /** The transport is running. */
23
+ playing?: boolean;
24
+ /** The agent is busy and Enter queues for after it. */
25
+ queue?: boolean;
26
+ }>;
27
+
28
+ /** FNV-1a: a small, stable string hash. */
29
+ export function seedHash(seed: string): number {
30
+ let hash = 0x811c9dc5;
31
+ for (let index = 0; index < seed.length; index += 1) {
32
+ hash ^= seed.charCodeAt(index);
33
+ hash = Math.imul(hash, 0x01000193) >>> 0;
34
+ }
35
+ return hash >>> 0;
36
+ }
37
+
38
+ /** The entry of `list` the seed picks; `salt` decorrelates two lists. */
39
+ export function pick<T>(list: readonly T[], seed: string, salt = ""): T {
40
+ return list[seedHash(`${salt}:${seed}`) % list.length]!;
41
+ }
42
+
43
+ /** Prose requests an agent understands, for the placeholder. */
44
+ const ASK_EMPTY = [
45
+ "describe a song — “a slow lofi loop in F with soft drums”",
46
+ "describe a song — “a four-bar house groove at 124 BPM”",
47
+ "describe a song — “a sad waltz for piano and cello”",
48
+ "describe a song — “a bright synth arpeggio in D major”",
49
+ "describe a song — “a dusty boom bap beat with a walking bass”",
50
+ ] as const;
51
+
52
+ const ASK_FILLED = [
53
+ "describe a change — “add a walking bass in A minor”",
54
+ "describe a change — “make the drums swing a little”",
55
+ "describe a change — “double the melody an octave up”",
56
+ "describe a change — “add reverb to the keys”",
57
+ "describe a change — “slow it to 90 BPM and add a fill”",
58
+ ] as const;
59
+
60
+ const ASK_DRUMS = [
61
+ "describe a change — “a ghost-note snare on the offbeats”",
62
+ "describe a change — “open hats on every and”",
63
+ "describe a change — “a crash on bar one, then a fill”",
64
+ ] as const;
65
+
66
+ /**
67
+ * Commands that run without an agent, for the placeholder. `{style}` is the
68
+ * same seeded style the wide empty line suggests, so the two lines never
69
+ * offer competing first steps.
70
+ */
71
+ const TYPE_EMPTY = [
72
+ "try: style {style} · space play · /help",
73
+ "try: style {style} 8 · then space to hear it",
74
+ "try: add C4 at 0 · add E4 at 1 · space play",
75
+ "try: style {style} · ctrl-z undo",
76
+ ] as const;
77
+
78
+ const TYPE_FILLED = [
79
+ "try: tempo 96 · reverb 0.3 · space play",
80
+ "try: style again · ctrl-z undo",
81
+ "try: add G4 at 2 · bars 8 · space play",
82
+ "try: volume 0.6 · pan -0.2 · /help",
83
+ ] as const;
84
+
85
+ const TYPE_DRUMS = [
86
+ "try: hit kick at 0 · hit snare at 1 · space play",
87
+ "try: euclid hat 7 16 · tempo 100",
88
+ ] as const;
89
+
90
+ /**
91
+ * The keys on the empty line (design §8.5): transport, play mode and the
92
+ * menu while paused; while playing, how to stop and, with an agent, that a
93
+ * request still works.
94
+ */
95
+ const KEYS_PAUSED = "space play · ctrl-p play mode · ctrl-k menu";
96
+ const KEYS_PLAYING_AGENT = "space stop · type a request";
97
+ const KEYS_PLAYING = "space stop · ctrl-p play mode";
98
+
99
+ /** How to start a drum track or a vocal track, before the keys. */
100
+ const LEAD_DRUMS = "hit kick at 0";
101
+ const LEAD_VOCAL = 'lyrics "la la" · sing ooh · ctrl-k › Voice';
102
+
103
+ /** Styles a wide empty line suggests, one per session (all have cards). */
104
+ export const SUGGESTED_STYLES = [
105
+ "deep-house",
106
+ "lofi-hip-hop",
107
+ "bossa-nova",
108
+ "nu-disco",
109
+ "synthwave",
110
+ "golden-age",
111
+ ] as const;
112
+
113
+ /** From this width the empty line also suggests a seeded style. */
114
+ export const STYLE_HINT_WIDTH = 100;
115
+
116
+ /** The prompt placeholder for this session and state. */
117
+ export function placeholderHint(state: HintState): string {
118
+ if (state.agent && state.queue) return "queue a request for after this one…";
119
+ const list = state.agent
120
+ ? state.drums && state.filled
121
+ ? ASK_DRUMS
122
+ : state.filled
123
+ ? ASK_FILLED
124
+ : ASK_EMPTY
125
+ : state.drums
126
+ ? TYPE_DRUMS
127
+ : state.filled
128
+ ? TYPE_FILLED
129
+ : TYPE_EMPTY;
130
+ return pick(list, state.seed, "placeholder").replace(
131
+ "{style}",
132
+ sessionStyle(state.seed),
133
+ );
134
+ }
135
+
136
+ /** The one style this session suggests, on the empty line and the prompt. */
137
+ export function sessionStyle(seed: string): string {
138
+ return pick(SUGGESTED_STYLES, seed, "style");
139
+ }
140
+
141
+ /**
142
+ * The empty-highway line, `<name> · empty · <lead> · <keys>`, shortened to
143
+ * `width`: the lead goes first, then the keys shrink to their first
144
+ * clause, then the name alone. A drum track leads with `hit kick at 0`, a
145
+ * vocal track with lyrics and sing, and from 100 columns a pitched track
146
+ * suggests a seeded style.
147
+ */
148
+ export function emptyHint(
149
+ state: HintState,
150
+ name: string,
151
+ width: number,
152
+ ): string {
153
+ const keys = state.playing
154
+ ? state.agent
155
+ ? KEYS_PLAYING_AGENT
156
+ : KEYS_PLAYING
157
+ : KEYS_PAUSED;
158
+ const lead = state.drums
159
+ ? LEAD_DRUMS
160
+ : state.vocal
161
+ ? LEAD_VOCAL
162
+ : width >= STYLE_HINT_WIDTH && !state.playing
163
+ ? `try style ${sessionStyle(state.seed)}`
164
+ : undefined;
165
+ const head = `${name} · empty`;
166
+ const candidates = [
167
+ ...(lead ? [`${head} · ${lead} · ${keys}`] : []),
168
+ `${head} · ${keys}`,
169
+ `${head} · ${keys.split(" · ")[0]}`,
170
+ ];
171
+ for (const candidate of candidates)
172
+ if (candidate.length <= width) return candidate;
173
+ return head.slice(0, Math.max(0, width));
174
+ }
175
+
176
+ /** Every hint string, for the shipped-text and command checks. */
177
+ export const ALL_HINTS: readonly string[] = [
178
+ ...ASK_EMPTY,
179
+ ...ASK_FILLED,
180
+ ...ASK_DRUMS,
181
+ ...TYPE_EMPTY.flatMap((hint) =>
182
+ SUGGESTED_STYLES.map((id) => hint.replace("{style}", id)),
183
+ ),
184
+ ...TYPE_FILLED,
185
+ ...TYPE_DRUMS,
186
+ KEYS_PAUSED,
187
+ KEYS_PLAYING_AGENT,
188
+ KEYS_PLAYING,
189
+ LEAD_DRUMS,
190
+ LEAD_VOCAL,
191
+ ...SUGGESTED_STYLES.map((id) => `try style ${id}`),
192
+ ];
package/tui/input.ts CHANGED
@@ -7,16 +7,42 @@
7
7
  export type TerminalInputEvent =
8
8
  string | Readonly<{ type: "paste"; text: string }>;
9
9
 
10
+ /** A bracketed paste longer than this is emitted as it stands. */
11
+ export const MAX_PASTE_CHARS = 1 << 20;
12
+
13
+ /** A chunk marker the read loop yields when input went idle mid-sequence. */
14
+ export const INPUT_FLUSH = "\u0000flush";
15
+
16
+ /** Idle time after which a partial escape prefix is a key of its own. */
17
+ export const ESCAPE_FLUSH_MS = 50;
18
+ /** Idle time after which an unterminated bracketed paste is emitted. */
19
+ export const PASTE_FLUSH_MS = 1_000;
20
+
10
21
  export class TerminalInputDecoder {
11
22
  private buffer = "";
12
23
 
24
+ /**
25
+ * What the decoder holds back: `escape` for a partial escape sequence,
26
+ * `paste` for an unterminated bracketed paste. The caller flushes after
27
+ * `ESCAPE_FLUSH_MS` or `PASTE_FLUSH_MS` with no further input.
28
+ */
29
+ pending(): "escape" | "paste" | undefined {
30
+ if (this.buffer.length === 0) return undefined;
31
+ return this.buffer.startsWith("\u001b[200~") ? "paste" : "escape";
32
+ }
33
+
13
34
  push(chunk: string): TerminalInputEvent[] {
14
35
  this.buffer += chunk;
15
36
  const events: TerminalInputEvent[] = [];
16
37
  while (this.buffer.length > 0) {
17
38
  if (this.buffer.startsWith("\u001b[200~")) {
18
39
  const end = this.buffer.indexOf("\u001b[201~", 6);
19
- if (end < 0) break;
40
+ if (end < 0) {
41
+ if (this.buffer.length - 6 < MAX_PASTE_CHARS) break;
42
+ events.push({ type: "paste", text: this.buffer.slice(6) });
43
+ this.buffer = "";
44
+ break;
45
+ }
20
46
  events.push({ type: "paste", text: this.buffer.slice(6, end) });
21
47
  this.buffer = this.buffer.slice(end + 6);
22
48
  continue;
@@ -38,12 +64,27 @@ export class TerminalInputDecoder {
38
64
  return events;
39
65
  }
40
66
 
67
+ /**
68
+ * Emits whatever is held back, once no more input is coming: a cut-off
69
+ * bracketed paste as a paste, a lone ESC as Esc, and a partial prefix such
70
+ * as `ESC [` (Alt+[) or `ESC O` (Alt+O) as one Alt key, then decodes the
71
+ * rest normally.
72
+ */
41
73
  flush(): TerminalInputEvent[] {
42
- if (this.buffer === "\u001b") {
74
+ const events: TerminalInputEvent[] = [];
75
+ while (this.buffer.length > 0) {
76
+ if (this.buffer.startsWith("\u001b[200~")) {
77
+ events.push({ type: "paste", text: this.buffer.slice(6) });
78
+ this.buffer = "";
79
+ break;
80
+ }
81
+ const head =
82
+ this.buffer.length === 1 ? "\u001b" : this.buffer.slice(0, 2);
83
+ const rest = this.buffer.slice(head.length);
43
84
  this.buffer = "";
44
- return ["\u001b"];
85
+ events.push(head, ...this.push(rest));
45
86
  }
46
- return [];
87
+ return events;
47
88
  }
48
89
 
49
90
  private readEscapeSequence(): string | undefined {
@@ -59,12 +100,22 @@ export class TerminalInputDecoder {
59
100
  return this.buffer.length >= 6 ? this.buffer.slice(0, 6) : undefined;
60
101
  const final = this.buffer.match(/^\u001b\[[\x20-?]*[\x40-~]/)?.[0];
61
102
  if (final !== undefined) return final;
62
- // SS3: arrows in application mode and F1-F4 (`ESC O P` is F1).
63
- const ss3 = this.buffer.match(/^\u001bO[A-DHFPQRS]/)?.[0];
64
- if (ss3 !== undefined) return ss3;
103
+ // A byte that cannot continue a CSI ends it: the `ESC [` was Alt+[.
104
+ if (this.buffer.startsWith("\u001b[")) {
105
+ const partial = this.buffer.match(/^\u001b\[[\x20-?]*/)![0];
106
+ if (partial.length < this.buffer.length) return "\u001b[";
107
+ }
108
+ // SS3: arrows in application mode and F1-F4 (`ESC O P` is F1). A bare
109
+ // `ESC O` waits for its final byte, which a PTY may deliver separately;
110
+ // `flush` turns it into Alt+O if nothing follows.
111
+ if (this.buffer.startsWith("\u001bO")) {
112
+ if (this.buffer.length === 2) return undefined;
113
+ const ss3 = this.buffer.match(/^\u001bO[A-DHFPQRS]/)?.[0];
114
+ if (ss3 !== undefined) return ss3;
115
+ return "\u001bO";
116
+ }
65
117
  // An unrecognised Alt-prefixed character is still one complete event.
66
- if (this.buffer.length >= 2 && this.buffer[1] !== "[")
67
- return this.buffer.slice(0, 2);
118
+ if (this.buffer[1] !== "[") return this.buffer.slice(0, 2);
68
119
  return undefined;
69
120
  }
70
121
  }
package/tui/keys.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The prompt is always focused, so printable keys always type. Control
5
5
  * chords carry the global actions:
6
- * Ctrl+C quit · Ctrl+Q toggle STEER/QUEUE · Ctrl+Z undo · Ctrl+Y redo
6
+ * Ctrl+C quit · Ctrl+Q toggle NOW/NEXT · Ctrl+Z undo · Ctrl+Y redo
7
7
  * Ctrl+O transcript overlay · Esc closes the overlay
8
8
  */
9
9