@hraness/dawg 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/tui/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 {
@@ -61,6 +80,8 @@ export interface TrackScoreSnapshot {
61
80
  */
62
81
  barBeats?: readonly number[] | undefined;
63
82
  loopBeats?: number | undefined;
83
+ /** The score's loop range (0-based start bar), for the header's `↻ 5–6`. */
84
+ loopRange?: Readonly<{ startBar: number; bars: number }> | undefined;
64
85
  laneCount?: number | undefined;
65
86
  /** Optional lane legend (drum voices) drawn just below the hit line. */
66
87
  laneLabels?: readonly string[] | undefined;
@@ -83,6 +104,49 @@ export interface TrackScoreSnapshot {
83
104
  * accent. Empty or omitted in focus view.
84
105
  */
85
106
  layers?: readonly HighwayLayer[] | undefined;
107
+ /** The focused track sings (instrument `vocal`): the empty hint says how. */
108
+ vocal?: boolean | undefined;
109
+ /** Audio clips on the focused track (0.7): the right-edge clip row. */
110
+ clips?: readonly ClipSnapshot[] | undefined;
111
+ /**
112
+ * Detected pitch of the focused track's audio (0.7 `/vocal pitch trace`),
113
+ * sorted by beat. Drawn as a dotted line under the notes, in the warning
114
+ * role where it sits more than 15 cents off the nearest semitone; hidden
115
+ * while one row spans more than a beat.
116
+ */
117
+ pitchTrace?: readonly PitchTracePoint[] | undefined;
118
+ }
119
+
120
+ /** One point of a pitch trace: score beats and fractional MIDI pitch. */
121
+ export interface PitchTracePoint {
122
+ beat: number;
123
+ pitch: number;
124
+ }
125
+
126
+ /** Cents a trace point sits away from its nearest semitone before it warns. */
127
+ export const TRACE_WARN_CENTS = 15;
128
+
129
+ /** The trace point nearest `beat` within `half` beats, by binary search. */
130
+ export function tracePointAt(
131
+ trace: readonly PitchTracePoint[],
132
+ beat: number,
133
+ half: number,
134
+ ): PitchTracePoint | undefined {
135
+ let lo = 0;
136
+ let hi = trace.length;
137
+ while (lo < hi) {
138
+ const mid = (lo + hi) >> 1;
139
+ if (trace[mid]!.beat < beat) lo = mid + 1;
140
+ else hi = mid;
141
+ }
142
+ let best: PitchTracePoint | undefined;
143
+ for (const i of [lo - 1, lo]) {
144
+ const point = trace[i];
145
+ if (!point || Math.abs(point.beat - beat) > half) continue;
146
+ if (!best || Math.abs(point.beat - beat) < Math.abs(best.beat - beat))
147
+ best = point;
148
+ }
149
+ return best;
86
150
  }
87
151
 
88
152
  /** One background track overlaid on the highway. */
@@ -216,7 +280,10 @@ export function pitchProjection(
216
280
  }
217
281
 
218
282
  /** Choose the projection a snapshot implies. */
219
- export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
283
+ export function projectionFor(
284
+ score: TrackScoreSnapshot,
285
+ minimumSpan = 12,
286
+ ): LaneProjection {
220
287
  if (score.projection) return score.projection;
221
288
  const explicit =
222
289
  score.laneLabels !== undefined ||
@@ -233,7 +300,7 @@ export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
233
300
  .flatMap((layer) => layer.notes);
234
301
  return pitchProjection(
235
302
  melodic.length ? [...score.notes, ...melodic] : score.notes,
236
- 12,
303
+ minimumSpan,
237
304
  score.tuningPeriod,
238
305
  );
239
306
  }
@@ -413,6 +480,12 @@ export interface HighwayOptions {
413
480
  lookaheadBeats?: number;
414
481
  /** Background style painted under the highway. */
415
482
  background?: Style;
483
+ /** Seed and agent presence for the empty-state hint (tui/hints.ts). */
484
+ hint?: Readonly<{ seed: string; agent: boolean }> | undefined;
485
+ /** The first-loop sweep's head, 0..1 across the hit line (tui/delight.ts). */
486
+ sweep?: number | undefined;
487
+ /** Play keys sounding without recording: their lanes glow on the hit line. */
488
+ glows?: readonly Readonly<{ pitch: number; strength: number }>[] | undefined;
416
489
  }
417
490
 
418
491
  export interface HighwayLayout {
@@ -426,6 +499,28 @@ export interface HighwayLayout {
426
499
  legendRow: number | undefined;
427
500
  }
428
501
 
502
+ /**
503
+ * The widest a lane gets. A wider terminal shows more pitches (up to
504
+ * `MAX_PITCH_SPAN`) instead of fatter tiles, and past that the lanes stay
505
+ * this wide, centred.
506
+ */
507
+ export const MAX_LANE_WIDTH = 10;
508
+ /** The most semitones a melodic highway spreads across on a wide terminal. */
509
+ export const MAX_PITCH_SPAN = 36;
510
+ /**
511
+ * The tallest a beat gets. A taller terminal looks further ahead (more bars
512
+ * coming) instead of stretching each note.
513
+ */
514
+ export const MAX_ROWS_PER_BEAT = 4;
515
+
516
+ /** The pitch span a highway `width` columns wide fits: one to three octaves. */
517
+ export function pitchSpanFor(width: number): number {
518
+ return Math.max(
519
+ 12,
520
+ Math.min(MAX_PITCH_SPAN, Math.floor((width - 3) / MAX_LANE_WIDTH)),
521
+ );
522
+ }
523
+
429
524
  export function highwayLayout(
430
525
  region: HighwayRegion,
431
526
  projection: LaneProjection,
@@ -437,13 +532,21 @@ export function highwayLayout(
437
532
  const gutter = region.width >= 40 ? 3 : 0;
438
533
  const area = Math.max(1, region.width - gutter);
439
534
  const laneCount = projection.laneCount;
440
- const laneWidth = laneCount <= area ? Math.floor(area / laneCount) : 0;
535
+ const laneWidth =
536
+ laneCount <= area
537
+ ? Math.min(MAX_LANE_WIDTH, Math.floor(area / laneCount))
538
+ : 0;
441
539
  const used = laneWidth > 0 ? laneWidth * laneCount : area;
442
540
  const laneLeft = gutter + Math.floor((area - used) / 2);
443
541
  const tileWidth =
444
542
  laneWidth >= 4 ? laneWidth - 1 : Math.max(1, laneWidth || 1);
445
543
  const lookahead =
446
- lookaheadBeats ?? Math.max(2, Math.min(8, Math.round(hitRow / 2.5)));
544
+ lookaheadBeats ??
545
+ Math.max(
546
+ 2,
547
+ Math.min(8, Math.round(hitRow / 2.5)),
548
+ Math.floor(hitRow / MAX_ROWS_PER_BEAT),
549
+ );
447
550
  return {
448
551
  hitRow,
449
552
  gutter,
@@ -473,6 +576,7 @@ interface Painter {
473
576
  put(x: number, y: number, ch: string, style: Style): void;
474
577
  restyle(x: number, y: number, style: Style): void;
475
578
  isBlank(x: number, y: number): boolean;
579
+ charAt(x: number, y: number): string | undefined;
476
580
  }
477
581
 
478
582
  /**
@@ -492,7 +596,7 @@ export function paintHighway(
492
596
  const glyphs = glyphsFor(capabilities);
493
597
  const background = options.background ?? roles.canvas;
494
598
  const bg = (style: Style): Style => onBackground(style, background);
495
- const projection = projectionFor(score);
599
+ const projection = projectionFor(score, pitchSpanFor(region.width));
496
600
  const layout = highwayLayout(region, projection, options.lookaheadBeats);
497
601
  const { hitRow, rowsPerBeat, gutter } = layout;
498
602
  const areaWidth = Math.max(1, region.width - gutter);
@@ -516,6 +620,9 @@ export function paintHighway(
516
620
  isBlank(x, y) {
517
621
  return buffer.get(region.x + x, region.y + y)?.ch === " ";
518
622
  },
623
+ charAt(x, y) {
624
+ return buffer.get(region.x + x, region.y + y)?.ch;
625
+ },
519
626
  };
520
627
 
521
628
  const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
@@ -525,13 +632,17 @@ export function paintHighway(
525
632
  // centred hint says how to start. The hit line and grid still draw.
526
633
  const empty =
527
634
  score.notes.length === 0 &&
635
+ (score.clips ?? []).length === 0 &&
528
636
  !(score.layers ?? []).some((layer) => layer.notes.length > 0);
529
637
 
530
638
  // Beat, bar, and loop rules of increasing strength.
531
639
  const barBeats =
532
640
  score.barBeats && score.barBeats.length > 0 ? score.barBeats : undefined;
641
+ // An empty highway reserves one row for its hint: no rule scrolls
642
+ // through it, so the words never sit on the ruler, playing or paused.
643
+ const hintRow = empty ? Math.max(0, Math.floor(hitRow / 2)) : undefined;
533
644
  for (let row = 0; row <= lastNoteRow; row += 1) {
534
- if (row === hitRow) continue;
645
+ if (row === hitRow || row === hintRow) continue;
535
646
  const center = rowBeat(row);
536
647
  const half = 0.5 / rowsPerBeat;
537
648
  let k = Math.ceil(center - half - 1e-9);
@@ -613,37 +724,40 @@ export function paintHighway(
613
724
  );
614
725
  }
615
726
 
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
- }
727
+ // First-loop sweep: once per song, a faint band crosses the hit line
728
+ // when the loop first wraps (tui/delight.ts decides when).
729
+ if (options.sweep !== undefined && !reduced) {
730
+ const head = Math.round(gutter + options.sweep * areaWidth);
731
+ for (let offset = 0; offset < 6; offset += 1) {
732
+ const column = head - offset;
733
+ if (column < gutter || column >= region.width) continue;
734
+ painter.put(
735
+ column,
736
+ hitRow,
737
+ glyphs.loopRule,
738
+ shade(roles.loopRule, 0.5 - offset * 0.1),
739
+ );
632
740
  }
633
741
  }
634
742
 
635
743
  if (empty) {
636
744
  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`,
745
+ // One seeded line names a way in that works in this session: a
746
+ // request with an agent, a command without one (tui/hints.ts).
747
+ const text = emptyHint(
748
+ {
749
+ seed: options.hint?.seed ?? score.sessionId ?? "dawg",
750
+ agent: options.hint?.agent ?? true,
751
+ filled: false,
752
+ drums: projection.kind !== "pitch",
753
+ vocal: score.vocal === true,
754
+ playing: score.playing === true,
755
+ },
756
+ name,
643
757
  areaWidth,
644
758
  );
645
759
  const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
646
- const y = Math.max(0, Math.floor(hitRow / 2));
760
+ const y = hintRow ?? 0;
647
761
  for (let index = 0; index < text.length; index += 1)
648
762
  painter.put(x + index, y, text[index]!, roles.muted);
649
763
  }
@@ -668,6 +782,34 @@ export function paintHighway(
668
782
  }
669
783
  }
670
784
 
785
+ // 0.7 pitch trace: one dot per row under the notes, at the fractional
786
+ // lane of the detected pitch. Rows wider than a beat hide it.
787
+ const trace = score.pitchTrace;
788
+ if (trace && trace.length > 0 && rowsPerBeat >= 1) {
789
+ const half = 0.5 / rowsPerBeat;
790
+ const width = Math.max(1, layout.laneWidth || 1);
791
+ for (let row = 0; row <= lastNoteRow; row += 1) {
792
+ if (row === hitRow) continue;
793
+ let at = rowBeat(row);
794
+ if (loop) at = ((at % loop) + loop) % loop;
795
+ const point = tracePointAt(trace, at, half);
796
+ if (!point) continue;
797
+ const near = Math.round(point.pitch);
798
+ const lane = projection.laneOf({ startBeat: point.beat, pitch: near });
799
+ if (lane === undefined) continue;
800
+ const off = point.pitch - near;
801
+ const x =
802
+ laneX(layout, lane, areaWidth) +
803
+ Math.max(0, Math.min(width - 1, Math.floor((off + 0.5) * width)));
804
+ painter.put(
805
+ x,
806
+ row,
807
+ glyphs.muted,
808
+ Math.abs(off) * 100 > TRACE_WARN_CENTS ? roles.warning : roles.muted,
809
+ );
810
+ }
811
+ }
812
+
671
813
  // Note occurrences across loop repeats, ordered so active notes draw last.
672
814
  const belowBeats = (lastNoteRow - hitRow + 1) / rowsPerBeat;
673
815
  const aheadBeats = hitRow / rowsPerBeat + 1 / rowsPerBeat;
@@ -835,11 +977,13 @@ export function paintHighway(
835
977
  : shade(base, 0.45 * glow);
836
978
  if (headRow <= hitRow - 1) {
837
979
  tile(headRow, headGlyph, style);
838
- const tag =
980
+ const cents =
839
981
  (note.cents !== undefined || note.centsFrom !== undefined) &&
840
982
  !note.muted
841
983
  ? centsTag(note.cents, note.centsFrom)
842
984
  : "";
985
+ const lyric = note.lyric && !note.muted ? note.lyric.slice(0, 8) : "";
986
+ const tag = [cents, lyric].filter(Boolean).join(" ");
843
987
  if (tag && x + width + tag.length <= region.width)
844
988
  for (let index = 0; index < tag.length; index += 1)
845
989
  painter.put(x + width + index, headRow, tag[index]!, roles.muted);
@@ -938,9 +1082,144 @@ export function paintHighway(
938
1082
  }
939
1083
  }
940
1084
  }
1085
+ // Ghost notes: a play key that is not recording lights its lane on the
1086
+ // hit line for a moment, so the hand sees where the music would land.
1087
+ if (options.glows && !reduced) {
1088
+ const width = Math.max(1, layout.laneWidth || 1);
1089
+ for (const glow of options.glows) {
1090
+ const lane = projection.laneOf({ startBeat: beat, pitch: glow.pitch });
1091
+ if (lane === undefined) continue;
1092
+ const x = laneX(layout, lane, areaWidth);
1093
+ const style = {
1094
+ ...shade(accent, 0.45 * glow.strength),
1095
+ bold: glow.strength > 0.5,
1096
+ };
1097
+ for (let offset = 0; offset < width; offset += 1)
1098
+ painter.put(x + offset, hitRow, glyphs.hitStrong, style);
1099
+ }
1100
+ }
1101
+ if (score.clips && score.clips.length > 0 && region.width >= 24)
1102
+ paintClipRow(painter, score, {
1103
+ beat,
1104
+ hitRow,
1105
+ lastNoteRow,
1106
+ rowsPerBeat,
1107
+ width: region.width,
1108
+ loop,
1109
+ glyphs,
1110
+ accent,
1111
+ roles,
1112
+ });
941
1113
  return layout;
942
1114
  }
943
1115
 
1116
+ function rowHasOnlyRule(
1117
+ painter: Painter,
1118
+ x: number,
1119
+ length: number,
1120
+ row: number,
1121
+ glyphs: HighwayGlyphs,
1122
+ ): boolean {
1123
+ for (let index = 0; index < length; index += 1) {
1124
+ const ch = painter.charAt(x + index, row);
1125
+ if (ch !== " " && ch !== glyphs.beatRule) return false;
1126
+ }
1127
+ return true;
1128
+ }
1129
+
1130
+ /** Shading for a clip's level, quiet to loud. */
1131
+ const CLIP_LEVELS_UNICODE = ["▏", "░", "▒", "▓", "█"];
1132
+ const CLIP_LEVELS_ASCII = [":", ".", "o", "O", "#"];
1133
+
1134
+ /**
1135
+ * The clip row: the rightmost column carries each clip's level as it
1136
+ * falls toward the hit line, a seam (`┄`) where one clip continues
1137
+ * another, and `id take · text` beside the clip's first row.
1138
+ */
1139
+ function paintClipRow(
1140
+ painter: Painter,
1141
+ score: TrackScoreSnapshot,
1142
+ at: {
1143
+ beat: number;
1144
+ hitRow: number;
1145
+ lastNoteRow: number;
1146
+ rowsPerBeat: number;
1147
+ width: number;
1148
+ loop: number | undefined;
1149
+ glyphs: HighwayGlyphs;
1150
+ accent: Style;
1151
+ roles: Theme["roles"];
1152
+ },
1153
+ ): void {
1154
+ const column = at.width - 1;
1155
+ const levels =
1156
+ at.glyphs === ASCII_GLYPHS ? CLIP_LEVELS_ASCII : CLIP_LEVELS_UNICODE;
1157
+ const seamGlyph = at.glyphs === ASCII_GLYPHS ? "-" : "┄";
1158
+ const rowBeat = (row: number) => at.beat + (at.hitRow - row) / at.rowsPerBeat;
1159
+ const half = 0.5 / at.rowsPerBeat;
1160
+ const occurrences = (clip: ClipSnapshot): number[] => {
1161
+ if (!at.loop) return [clip.startBeat];
1162
+ const base = Math.floor(at.beat / at.loop) * at.loop;
1163
+ return [base - at.loop, base, base + at.loop].map(
1164
+ (offset) => offset + clip.startBeat,
1165
+ );
1166
+ };
1167
+ for (const clip of score.clips ?? []) {
1168
+ const style = clip.muted ? at.roles.mutedNote : at.accent;
1169
+ for (const start of occurrences(clip)) {
1170
+ const end = start + clip.durationBeats;
1171
+ for (let row = 0; row <= at.lastNoteRow; row += 1) {
1172
+ if (row === at.hitRow) continue;
1173
+ const center = rowBeat(row);
1174
+ if (center + half <= start || center - half >= end) continue;
1175
+ const isStart = start >= center - half && start < center + half;
1176
+ if (isStart && clip.seam) {
1177
+ painter.put(column, row, seamGlyph, at.roles.muted);
1178
+ } else {
1179
+ const peaks = clip.peaks;
1180
+ const fraction = Math.max(
1181
+ 0,
1182
+ Math.min(
1183
+ 0.999,
1184
+ (center - start) / Math.max(1e-9, clip.durationBeats),
1185
+ ),
1186
+ );
1187
+ const level =
1188
+ peaks && peaks.length > 0
1189
+ ? peaks[Math.floor(fraction * peaks.length)]!
1190
+ : 0.6;
1191
+ const index = clip.muted
1192
+ ? 0
1193
+ : Math.min(
1194
+ levels.length - 1,
1195
+ Math.round(level * (levels.length - 1)),
1196
+ );
1197
+ painter.put(column, row, levels[index]!, style);
1198
+ }
1199
+ if (isStart && row < at.hitRow) {
1200
+ const label = [clip.id, clip.take, clip.text ? `· ${clip.text}` : ""]
1201
+ .filter(Boolean)
1202
+ .join(" ")
1203
+ .slice(0, Math.max(0, Math.min(24, at.width - 6)));
1204
+ const x = column - 1 - label.length;
1205
+ // Over a beat rule the whole label draws; over notes it never does.
1206
+ const ruled = rowHasOnlyRule(
1207
+ painter,
1208
+ x,
1209
+ label.length,
1210
+ row,
1211
+ at.glyphs,
1212
+ );
1213
+ if (x > 3)
1214
+ for (let index = 0; index < label.length; index += 1)
1215
+ if (painter.isBlank(x + index, row) || ruled)
1216
+ painter.put(x + index, row, label[index]!, at.roles.muted);
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+
944
1223
  /**
945
1224
  * A background track: a quiet head and sustain line in the track's accent,
946
1225
  * no hit flash, sparks or ghosts, so the focused track stays readable.
package/tui/hints.ts ADDED
@@ -0,0 +1,197 @@
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
+ "try: ctrl-t tape · loop 1-2 · space play",
84
+ "try: copy all 1-4 to 5 · loop 5-8",
85
+ "try: knobs · mix · /help mix",
86
+ "try: panes · pane tape · /help panes",
87
+ "try: loop 1-2 · keys record · ctrl-z takes back a pass",
88
+ ] as const;
89
+
90
+ const TYPE_DRUMS = [
91
+ "try: hit kick at 0 · hit snare at 1 · space play",
92
+ "try: euclid hat 7 16 · tempo 100",
93
+ ] as const;
94
+
95
+ /**
96
+ * The keys on the empty line (design §8.5): transport, play mode and the
97
+ * menu while paused; while playing, how to stop and, with an agent, that a
98
+ * request still works.
99
+ */
100
+ const KEYS_PAUSED = "space play · ctrl-p play mode · ctrl-k menu";
101
+ const KEYS_PLAYING_AGENT = "space stop · type a request";
102
+ const KEYS_PLAYING = "space stop · ctrl-p play mode";
103
+
104
+ /** How to start a drum track or a vocal track, before the keys. */
105
+ const LEAD_DRUMS = "hit kick at 0";
106
+ const LEAD_VOCAL = 'lyrics "la la" · sing ooh · ctrl-k › Voice';
107
+
108
+ /** Styles a wide empty line suggests, one per session (all have cards). */
109
+ export const SUGGESTED_STYLES = [
110
+ "deep-house",
111
+ "lofi-hip-hop",
112
+ "bossa-nova",
113
+ "nu-disco",
114
+ "synthwave",
115
+ "golden-age",
116
+ ] as const;
117
+
118
+ /** From this width the empty line also suggests a seeded style. */
119
+ export const STYLE_HINT_WIDTH = 100;
120
+
121
+ /** The prompt placeholder for this session and state. */
122
+ export function placeholderHint(state: HintState): string {
123
+ if (state.agent && state.queue) return "queue a request for after this one…";
124
+ const list = state.agent
125
+ ? state.drums && state.filled
126
+ ? ASK_DRUMS
127
+ : state.filled
128
+ ? ASK_FILLED
129
+ : ASK_EMPTY
130
+ : state.drums
131
+ ? TYPE_DRUMS
132
+ : state.filled
133
+ ? TYPE_FILLED
134
+ : TYPE_EMPTY;
135
+ return pick(list, state.seed, "placeholder").replace(
136
+ "{style}",
137
+ sessionStyle(state.seed),
138
+ );
139
+ }
140
+
141
+ /** The one style this session suggests, on the empty line and the prompt. */
142
+ export function sessionStyle(seed: string): string {
143
+ return pick(SUGGESTED_STYLES, seed, "style");
144
+ }
145
+
146
+ /**
147
+ * The empty-highway line, `<name> · empty · <lead> · <keys>`, shortened to
148
+ * `width`: the lead goes first, then the keys shrink to their first
149
+ * clause, then the name alone. A drum track leads with `hit kick at 0`, a
150
+ * vocal track with lyrics and sing, and from 100 columns a pitched track
151
+ * suggests a seeded style.
152
+ */
153
+ export function emptyHint(
154
+ state: HintState,
155
+ name: string,
156
+ width: number,
157
+ ): string {
158
+ const keys = state.playing
159
+ ? state.agent
160
+ ? KEYS_PLAYING_AGENT
161
+ : KEYS_PLAYING
162
+ : KEYS_PAUSED;
163
+ const lead = state.drums
164
+ ? LEAD_DRUMS
165
+ : state.vocal
166
+ ? LEAD_VOCAL
167
+ : width >= STYLE_HINT_WIDTH && !state.playing
168
+ ? `try style ${sessionStyle(state.seed)}`
169
+ : undefined;
170
+ const head = `${name} · empty`;
171
+ const candidates = [
172
+ ...(lead ? [`${head} · ${lead} · ${keys}`] : []),
173
+ `${head} · ${keys}`,
174
+ `${head} · ${keys.split(" · ")[0]}`,
175
+ ];
176
+ for (const candidate of candidates)
177
+ if (candidate.length <= width) return candidate;
178
+ return head.slice(0, Math.max(0, width));
179
+ }
180
+
181
+ /** Every hint string, for the shipped-text and command checks. */
182
+ export const ALL_HINTS: readonly string[] = [
183
+ ...ASK_EMPTY,
184
+ ...ASK_FILLED,
185
+ ...ASK_DRUMS,
186
+ ...TYPE_EMPTY.flatMap((hint) =>
187
+ SUGGESTED_STYLES.map((id) => hint.replace("{style}", id)),
188
+ ),
189
+ ...TYPE_FILLED,
190
+ ...TYPE_DRUMS,
191
+ KEYS_PAUSED,
192
+ KEYS_PLAYING_AGENT,
193
+ KEYS_PLAYING,
194
+ LEAD_DRUMS,
195
+ LEAD_VOCAL,
196
+ ...SUGGESTED_STYLES.map((id) => `try style ${id}`),
197
+ ];
package/tui/hits.ts CHANGED
@@ -27,7 +27,13 @@ export type HitTarget =
27
27
  /** The piano roll / highway area. */
28
28
  | { kind: "highway" }
29
29
  /** Scrollable text (`/help`, the transcript, the `?` panel). */
30
- | { kind: "text" };
30
+ | { kind: "text" }
31
+ /** TAPE's ruler: column `x - left + firstCell` is a cell (jump, loop drag). */
32
+ | { kind: "tape-ruler"; left: number; firstCell: number }
33
+ /** A TAPE track row (focus; shift-drag sets the loop). */
34
+ | { kind: "tape-row"; row: number; left: number; firstCell: number }
35
+ /** TAPE's clipboard chip (click pastes). */
36
+ | { kind: "tape-clipboard" };
31
37
 
32
38
  export interface HitRegion {
33
39
  x: number;