@hraness/dawg 0.7.0 → 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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
package/tui/drawer.ts CHANGED
@@ -24,6 +24,7 @@ import type { HitMap } from "./hits.ts";
24
24
  import { onBackground, type Style, type Theme } from "./theme.ts";
25
25
  import { displayWidth, truncate } from "./text.ts";
26
26
  import { asciiHint, fitHint } from "./grammar.ts";
27
+ import { knobGlyph, knobStyle, type KnobIndex } from "./knobs.ts";
27
28
 
28
29
  export type DrawerField =
29
30
  | Readonly<{
@@ -41,6 +42,10 @@ export type DrawerField =
41
42
  maxText: string;
42
43
  /** Just caught by a detent: the bar brightens for one frame. */
43
44
  flash?: string | undefined;
45
+ /** Other panes with this parameter open (`B C`), dim at the right. */
46
+ peers?: string | undefined;
47
+ /** The knob this row is (front page): its glyph and colour. */
48
+ knob?: KnobIndex | undefined;
44
49
  }>
45
50
  | Readonly<{
46
51
  kind: "choice";
@@ -48,6 +53,8 @@ export type DrawerField =
48
53
  options: readonly string[];
49
54
  index: number;
50
55
  committedIndex?: number | undefined;
56
+ peers?: string | undefined;
57
+ knob?: KnobIndex | undefined;
51
58
  }>;
52
59
 
53
60
  export type DrawerView = Readonly<{
@@ -63,8 +70,35 @@ export type DrawerView = Readonly<{
63
70
  /** `♪ solo · B` while the audition loop plays. */
64
71
  status?: string | undefined;
65
72
  hint: string;
73
+ /**
74
+ * Every param on the page behind Tab, while the drawer shows the four
75
+ * knobs: a wide drawer lists them beside the knobs (`DRAWER_MEASURE`).
76
+ */
77
+ aside?: readonly DrawerAside[] | undefined;
66
78
  }>;
67
79
 
80
+ /** One row of the drawer's side list: a param and its value. */
81
+ export type DrawerAside = Readonly<{
82
+ label: string;
83
+ text: string;
84
+ knob?: KnobIndex | undefined;
85
+ }>;
86
+
87
+ /**
88
+ * The widest a drawer row (label, value, bar, range) runs: a fader bar past
89
+ * this is no easier to read or aim. Room right of it lists the page's other
90
+ * params when the view has an aside, else stays panel.
91
+ */
92
+ export const DRAWER_MEASURE = 96;
93
+ /** The fewest columns the side list needs beside the rows. */
94
+ export const ASIDE_MIN_WIDTH = 28;
95
+
96
+ /** Columns the side list gets in a drawer `width` wide (0: none). */
97
+ export function asideWidth(width: number): number {
98
+ const room = width - 4 - DRAWER_MEASURE - 3;
99
+ return room >= ASIDE_MIN_WIDTH ? Math.min(room, 48) : 0;
100
+ }
101
+
68
102
  export type DrawerMode = "full" | "compact" | "line";
69
103
 
70
104
  export interface DrawerLayout {
@@ -89,12 +123,20 @@ export function keepAbove(regionHeight: number): number {
89
123
  * its bottom, as tall as its fields need within the budget.
90
124
  */
91
125
  export function drawerLayout(
92
- view: Pick<DrawerView, "fields" | "focus">,
126
+ view: Pick<DrawerView, "fields" | "focus"> &
127
+ Partial<Pick<DrawerView, "aside">>,
93
128
  region: { y: number; height: number },
129
+ width = 0,
94
130
  ): DrawerLayout {
95
131
  const total = Math.max(1, view.fields.length);
96
132
  const bottom = region.y + region.height;
97
133
  const budget = region.height - keepAbove(region.height);
134
+ // A side list beside the knobs may make a full drawer taller, never
135
+ // past half the region.
136
+ const aside =
137
+ view.aside && asideWidth(width) > 0
138
+ ? Math.min(view.aside.length + 1, Math.floor(region.height / 2) - 2)
139
+ : 0;
98
140
  const window = (rows: number): { first: number; count: number } => {
99
141
  const count = Math.max(1, Math.min(total, rows));
100
142
  const focus = Math.min(Math.max(0, view.focus), total - 1);
@@ -102,7 +144,7 @@ export function drawerLayout(
102
144
  return { first, count };
103
145
  };
104
146
  if (2 + 2 * total <= Math.floor(region.height / 2)) {
105
- const height = 2 + 2 * total;
147
+ const height = 2 + Math.max(2 * total, aside);
106
148
  return {
107
149
  mode: "full",
108
150
  top: bottom - height,
@@ -197,7 +239,7 @@ export function paintDrawer(
197
239
  ): DrawerLayout | undefined {
198
240
  if (region.height <= 0 || width < 16 || view.fields.length === 0)
199
241
  return undefined;
200
- const layout = drawerLayout(view, region);
242
+ const layout = drawerLayout(view, region, width);
201
243
  const flashing = !options.reducedMotion && !flashed.has(view);
202
244
  if (view.fields.some((field) => field.kind === "number" && field.flash))
203
245
  flashed.add(view);
@@ -290,6 +332,19 @@ export function paintDrawer(
290
332
  on(roles.muted),
291
333
  );
292
334
  }
335
+ const outer = inner;
336
+ inner = {
337
+ left: inner.left,
338
+ right: Math.min(inner.right, inner.left + DRAWER_MEASURE),
339
+ };
340
+ if (layout.bordered && view.aside)
341
+ paintAside(buffer, layout, outer.right, view.aside, {
342
+ glyphs,
343
+ roles,
344
+ on,
345
+ theme: options.theme,
346
+ unicode: options.unicode,
347
+ });
293
348
  const labelWidth = Math.min(
294
349
  layout.mode === "full" ? 22 : 16,
295
350
  Math.max(...view.fields.map((field) => displayWidth(field.label))),
@@ -308,12 +363,25 @@ export function paintDrawer(
308
363
  const labelStyle = focused
309
364
  ? on({ ...roles.borderFocus, bold: true })
310
365
  : on(roles.text);
311
- buffer.text(inner.left, y, marker, labelStyle);
366
+ if (field.knob !== undefined) {
367
+ // A knob row: its shape glyph in its colour, then the marker, so
368
+ // colour is never the only cue (NO_COLOR keeps the shape).
369
+ buffer.text(
370
+ inner.left,
371
+ y,
372
+ knobGlyph(field.knob, options.unicode),
373
+ on(knobStyle(field.knob, options.theme)),
374
+ );
375
+ buffer.text(inner.left + 1, y, focused ? glyphs.marker : " ", labelStyle);
376
+ } else buffer.text(inner.left, y, marker, labelStyle);
312
377
  buffer.text(
313
378
  inner.left + 2,
314
379
  y,
315
380
  truncate(field.label, labelWidth),
316
- labelStyle,
381
+ // The selected knob's label is reverse video (§7.2).
382
+ field.knob !== undefined && focused
383
+ ? on({ ...roles.text, bold: true, reverse: true })
384
+ : labelStyle,
317
385
  );
318
386
  const valueX = inner.left + 2 + labelWidth + 2;
319
387
  const typing = focused && view.typing !== undefined;
@@ -353,6 +421,7 @@ export function paintDrawer(
353
421
  flashing,
354
422
  roles,
355
423
  on,
424
+ knob: knobFill(field, options.theme),
356
425
  ...(hits ? { hits } : {}),
357
426
  });
358
427
  } else {
@@ -373,7 +442,15 @@ export function paintDrawer(
373
442
  inner.right,
374
443
  field,
375
444
  index,
376
- { glyphs, focused, flashing, roles, on, ...(hits ? { hits } : {}) },
445
+ {
446
+ glyphs,
447
+ focused,
448
+ flashing,
449
+ roles,
450
+ on,
451
+ knob: knobFill(field, options.theme),
452
+ ...(hits ? { hits } : {}),
453
+ },
377
454
  );
378
455
  }
379
456
  } else {
@@ -395,10 +472,87 @@ export function paintDrawer(
395
472
  );
396
473
  }
397
474
  }
475
+ // Other panes with this parameter open: their letters, dim, at the
476
+ // row's right edge (design §12.7).
477
+ if (field.peers) {
478
+ const peers = ` ${field.peers}`;
479
+ buffer.text(
480
+ Math.max(valueX, inner.right - displayWidth(peers)),
481
+ y,
482
+ peers,
483
+ on(roles.muted),
484
+ );
485
+ }
398
486
  }
399
487
  return layout;
400
488
  }
401
489
 
490
+ /**
491
+ * `all params · tab` and every param on the page, one per row, beside the
492
+ * knobs: what Tab would show, without leaving the knobs.
493
+ */
494
+ function paintAside(
495
+ buffer: CellBuffer,
496
+ layout: DrawerLayout,
497
+ right: number,
498
+ aside: readonly DrawerAside[],
499
+ paint: {
500
+ glyphs: Glyphs;
501
+ roles: Theme["roles"];
502
+ on: (style: Style) => Style;
503
+ theme: Theme;
504
+ unicode: boolean;
505
+ },
506
+ ): void {
507
+ const width = asideWidth(right + 2);
508
+ if (width === 0) return;
509
+ const { roles, on } = paint;
510
+ const left = right - width;
511
+ const rows = layout.height - 2;
512
+ if (rows < 2) return;
513
+ const top = layout.top + 1;
514
+ const rule = paint.unicode ? "│" : "|";
515
+ for (let y = top; y < top + rows; y += 1)
516
+ buffer.set(left - 2, y, rule, on(roles.faint));
517
+ buffer.text(left, top, truncate("all params · tab", width), on(roles.muted));
518
+ const room = rows - 1;
519
+ const shown =
520
+ aside.length > room ? aside.slice(0, Math.max(0, room - 1)) : aside;
521
+ const labelWidth = Math.min(
522
+ Math.floor(width / 2),
523
+ Math.max(0, ...shown.map((row) => displayWidth(row.label))),
524
+ );
525
+ shown.forEach((row, index) => {
526
+ const y = top + 1 + index;
527
+ if (row.knob !== undefined)
528
+ buffer.text(
529
+ left,
530
+ y,
531
+ knobGlyph(row.knob, paint.unicode),
532
+ on(knobStyle(row.knob, paint.theme)),
533
+ );
534
+ buffer.text(left + 2, y, truncate(row.label, labelWidth), on(roles.text));
535
+ const valueX = left + 2 + labelWidth + 1;
536
+ buffer.text(
537
+ valueX,
538
+ y,
539
+ truncate(row.text, Math.max(0, right - valueX)),
540
+ on(roles.muted),
541
+ );
542
+ });
543
+ if (shown.length < aside.length)
544
+ buffer.text(
545
+ left + 2,
546
+ top + room,
547
+ truncate(`+${aside.length - shown.length} more · tab`, width - 2),
548
+ on(roles.faint),
549
+ );
550
+ }
551
+
552
+ function knobFill(field: DrawerField, theme: Theme): Style | undefined {
553
+ return field.knob === undefined ? undefined : knobStyle(field.knob, theme);
554
+ }
555
+
402
556
  interface RowPaint {
403
557
  glyphs: Glyphs;
404
558
  focused: boolean;
@@ -407,6 +561,8 @@ interface RowPaint {
407
561
  roles: Theme["roles"];
408
562
  on: (style: Style) => Style;
409
563
  hits?: HitMap;
564
+ /** A knob row's bar fills in its knob colour. */
565
+ knob?: Style | undefined;
410
566
  }
411
567
 
412
568
  /** `[−] ━━━━━●─────── [+]` from `left` to `right`, with its hit regions. */
@@ -457,9 +613,11 @@ function paintBar(
457
613
  const filled = on(
458
614
  flash
459
615
  ? { ...roles.success, bold: true }
460
- : paint.focused
461
- ? roles.borderFocus
462
- : roles.text,
616
+ : paint.knob
617
+ ? paint.knob
618
+ : paint.focused
619
+ ? roles.borderFocus
620
+ : roles.text,
463
621
  );
464
622
  const empty = on(roles.faint);
465
623
  for (let column = 0; column < barWidth; column += 1) {
package/tui/grammar.ts CHANGED
@@ -126,7 +126,7 @@ export const HINTS = {
126
126
  action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
127
127
  typing: " type a value · enter apply · esc clear ",
128
128
  euclid:
129
- " ↑↓ drum · ←→ adjust · tab field · space loop · x off · esc back · ? keys ",
129
+ " ↑↓ knob · ←→ turn · tab field · space loop · x off · esc back · ? keys ",
130
130
  text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
131
131
  log: " ↑↓ scroll · / filter · esc back · ? keys ",
132
132
  keys: " esc or ? closes ",
@@ -289,9 +289,9 @@ export const KEYS = {
289
289
  {
290
290
  title: "rhythm editor",
291
291
  rows: [
292
- ["↑ ↓ j k", "drum"],
293
- ["← → h l - +", "adjust the field"],
294
- ["tab shift-tab ] [", "next / previous field"],
292
+ ["↑ ↓ j k", "knob: ● drum ▲ pulses ■ rotate ◆ velocity"],
293
+ ["← → h l - +", "turn it (● picks the drum row)"],
294
+ ["tab shift-tab ] [", "next / previous field, every field"],
295
295
  ["0-9", "type a value, enter applies"],
296
296
  ["enter", "add a row (or type a value) · staged: keep"],
297
297
  ["x d delete", "turn the row off"],
@@ -347,7 +347,7 @@ export const KEYS = {
347
347
  ["shift", "sustain while held"],
348
348
  ["tab", "sustain latch (play mode only)"],
349
349
  ["space", "play / pause (with count-in)"],
350
- ["r R", "record · record replacing"],
350
+ ["r R", "record · replace · keys record (a pass an undo)"],
351
351
  ["m", "metronome click"],
352
352
  ["i", "scale degrees ⇄ chromatic (home row in key)"],
353
353
  ["q", "chord mode: auto ⇄ manual"],
@@ -357,6 +357,35 @@ export const KEYS = {
357
357
  ],
358
358
  },
359
359
  ],
360
+ tape: [
361
+ {
362
+ title: "tape · every track across the bars (each key echoes its command)",
363
+ rows: [
364
+ ["space", "play / pause from the playhead"],
365
+ ["↑ ↓", "pick a knob: ● playhead ▲ loop ■ tempo ◆ volume"],
366
+ ["← → ⇧", "turn it (⇧ coarse) · jump 5.3 · loop 5-7 · tempo 96"],
367
+ [", .", "previous / next section · jump chorus"],
368
+ ["< >", "slide the loop by its length · loop 7-8"],
369
+ ["[ ]", "loop end a bar shorter / longer · loop 5-7"],
370
+ ["{ }", "loop start a bar earlier / later · loop 4-7"],
371
+ ["\\", "loop the section here; again: off · loop chorus"],
372
+ ["1-9 tab", "focus a track row; tab: the next · track drums"],
373
+ ["c C", "copy the range (track / all) · copy bass 5-6"],
374
+ ["x X", "cut: copy, then clear · clear bass 5-6"],
375
+ ["v V", "paste at the playhead (V inserts) · copy bass 5-6 to 7"],
376
+ ["delete", "clear the range · clear bass 5-6"],
377
+ ["~", "reverse the range · reverse bass 5-6"],
378
+ ["s S", "split / join the section here · section split verse at 3"],
379
+ ["h H", "mute / solo the track · mute bass · solo"],
380
+ ["r R", "record the loop in play, a pass an undo · keys record"],
381
+ ["m b", "click · stop"],
382
+ ["- =", "zoom out / in (bar, beat, half beat a cell)"],
383
+ ["enter", "the knob big in the drawer (reset there: x)"],
384
+ ["/", "type a command, staying on tape"],
385
+ ["esc ctrl-t", "back home · tape"],
386
+ ],
387
+ },
388
+ ],
360
389
  chords: [
361
390
  {
362
391
  title: "chord mode · number row latches",
package/tui/guide.ts CHANGED
@@ -119,6 +119,8 @@ export class GuideBrowser {
119
119
  /** The guide open as a page, or undefined in the tree. */
120
120
  page: string | undefined;
121
121
  scroll = 0;
122
+ /** At the last page: resizes keep the end in view. */
123
+ private atEnd = false;
122
124
 
123
125
  constructor(guides: readonly Guide[], open?: string) {
124
126
  this.guides = guides;
@@ -146,6 +148,7 @@ export class GuideBrowser {
146
148
  this.selected = guide.id;
147
149
  this.page = guide.id;
148
150
  this.scroll = 0;
151
+ this.atEnd = false;
149
152
  return true;
150
153
  }
151
154
 
@@ -212,6 +215,7 @@ export class GuideBrowser {
212
215
  if (this.visible().some((row) => row.guide.id === this.selected)) {
213
216
  this.page = this.selected;
214
217
  this.scroll = 0;
218
+ this.atEnd = false;
215
219
  }
216
220
  }
217
221
 
@@ -244,11 +248,13 @@ export class GuideBrowser {
244
248
  : 0;
245
249
  if (step !== 0) {
246
250
  this.scroll = Math.max(0, Math.min(max, this.scroll + step));
251
+ this.atEnd = max > 0 && this.scroll === max;
247
252
  return "handled";
248
253
  }
249
254
  if (esc || left || value === "h" || value === "\u007f") {
250
255
  this.page = undefined;
251
256
  this.scroll = 0;
257
+ this.atEnd = false;
252
258
  return "handled";
253
259
  }
254
260
  return "handled";
@@ -339,7 +345,8 @@ export class GuideBrowser {
339
345
  if (page) {
340
346
  const rows = wrapRows(guideLines(page.body, unicode), width);
341
347
  const max = Math.max(0, rows.length - height);
342
- this.scroll = Math.max(0, Math.min(max, this.scroll));
348
+ // Pinned to the end at the last page: a resize keeps the end in view.
349
+ this.scroll = this.atEnd ? max : Math.max(0, Math.min(max, this.scroll));
343
350
  const crumb = page.parent ? `${this.byId(page.parent)?.title} › ` : "";
344
351
  return {
345
352
  title: `guide · ${crumb}${page.title}`.replace(
package/tui/highway.ts CHANGED
@@ -80,6 +80,8 @@ export interface TrackScoreSnapshot {
80
80
  */
81
81
  barBeats?: readonly number[] | undefined;
82
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;
83
85
  laneCount?: number | undefined;
84
86
  /** Optional lane legend (drum voices) drawn just below the hit line. */
85
87
  laneLabels?: readonly string[] | undefined;
@@ -278,7 +280,10 @@ export function pitchProjection(
278
280
  }
279
281
 
280
282
  /** Choose the projection a snapshot implies. */
281
- export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
283
+ export function projectionFor(
284
+ score: TrackScoreSnapshot,
285
+ minimumSpan = 12,
286
+ ): LaneProjection {
282
287
  if (score.projection) return score.projection;
283
288
  const explicit =
284
289
  score.laneLabels !== undefined ||
@@ -295,7 +300,7 @@ export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
295
300
  .flatMap((layer) => layer.notes);
296
301
  return pitchProjection(
297
302
  melodic.length ? [...score.notes, ...melodic] : score.notes,
298
- 12,
303
+ minimumSpan,
299
304
  score.tuningPeriod,
300
305
  );
301
306
  }
@@ -494,6 +499,28 @@ export interface HighwayLayout {
494
499
  legendRow: number | undefined;
495
500
  }
496
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
+
497
524
  export function highwayLayout(
498
525
  region: HighwayRegion,
499
526
  projection: LaneProjection,
@@ -505,13 +532,21 @@ export function highwayLayout(
505
532
  const gutter = region.width >= 40 ? 3 : 0;
506
533
  const area = Math.max(1, region.width - gutter);
507
534
  const laneCount = projection.laneCount;
508
- 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;
509
539
  const used = laneWidth > 0 ? laneWidth * laneCount : area;
510
540
  const laneLeft = gutter + Math.floor((area - used) / 2);
511
541
  const tileWidth =
512
542
  laneWidth >= 4 ? laneWidth - 1 : Math.max(1, laneWidth || 1);
513
543
  const lookahead =
514
- 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
+ );
515
550
  return {
516
551
  hitRow,
517
552
  gutter,
@@ -561,7 +596,7 @@ export function paintHighway(
561
596
  const glyphs = glyphsFor(capabilities);
562
597
  const background = options.background ?? roles.canvas;
563
598
  const bg = (style: Style): Style => onBackground(style, background);
564
- const projection = projectionFor(score);
599
+ const projection = projectionFor(score, pitchSpanFor(region.width));
565
600
  const layout = highwayLayout(region, projection, options.lookaheadBeats);
566
601
  const { hitRow, rowsPerBeat, gutter } = layout;
567
602
  const areaWidth = Math.max(1, region.width - gutter);
package/tui/hints.ts CHANGED
@@ -80,6 +80,11 @@ const TYPE_FILLED = [
80
80
  "try: style again · ctrl-z undo",
81
81
  "try: add G4 at 2 · bars 8 · space play",
82
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",
83
88
  ] as const;
84
89
 
85
90
  const TYPE_DRUMS = [
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;