@hraness/dawg 0.4.1 → 0.5.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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
package/tui/guide.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The `/guide` pane: the user guides (`guides/*.md`) as a tree that opens a
3
+ * guide into a page. Pure: keys go in, rows come out; `tui/app.ts` paints.
4
+ *
5
+ * tree ↑↓ (j k) move · → l enter expand, then open · ← h collapse or
6
+ * go to the parent · / filter (title and text) · esc back
7
+ * page ↑↓ (j k) scroll · pgup pgdn home end · ← h esc back to the tree
8
+ */
9
+ import type { Guide } from "../guides/index.ts";
10
+ import { overlayKey } from "./keys.ts";
11
+ import { displayWidth } from "./text.ts";
12
+
13
+ export type GuideRow = Readonly<{
14
+ text: string;
15
+ /** The highlighted tree row. */
16
+ selected?: boolean;
17
+ /** A `##` heading in a page. */
18
+ heading?: boolean;
19
+ /** Secondary text (a filter line, an empty result). */
20
+ muted?: boolean;
21
+ }>;
22
+
23
+ export type GuideView = Readonly<{
24
+ title: string;
25
+ rows: readonly GuideRow[];
26
+ /** First row shown and the total, for a page longer than the pane. */
27
+ scroll: number;
28
+ hint: string;
29
+ }>;
30
+
31
+ export const GUIDE_HINTS = {
32
+ tree: " ↑↓ move · → open · ← close · / filter · esc back · ? keys ",
33
+ filtering: " type to filter · enter open · esc clear · ? keys ",
34
+ page: " ↑↓ scroll · ← back · esc back · ? keys ",
35
+ } as const;
36
+
37
+ /**
38
+ * `## Ask` → `── Ask`, `- x` → `• x`, code spans lose their backticks. The
39
+ * blank line Markdown wants after a heading is dropped: in a pane the rule
40
+ * already separates it.
41
+ */
42
+ export function guideLines(body: string, unicode = true): GuideRow[] {
43
+ const lines = body
44
+ .split("\n")
45
+ .filter(
46
+ (line, index, all) =>
47
+ line.trim() !== "" || !/^#{1,6}\s/.test(all[index - 1] ?? ""),
48
+ );
49
+ return lines.map((line) => {
50
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
51
+ if (heading)
52
+ return { text: `${unicode ? "──" : "--"} ${heading[1]}`, heading: true };
53
+ const plain = line.replace(/`([^`]*)`/g, "$1");
54
+ return { text: plain.replace(/^- /, unicode ? "• " : "- ") };
55
+ });
56
+ }
57
+
58
+ /** Word-wrap rows to `width` columns; continuation lines indent under bullets. */
59
+ export function wrapRows(rows: readonly GuideRow[], width: number): GuideRow[] {
60
+ const out: GuideRow[] = [];
61
+ for (const row of rows) {
62
+ if (displayWidth(row.text) <= width || width < 10) {
63
+ out.push(row);
64
+ continue;
65
+ }
66
+ const indent = /^[•-] /.test(row.text) ? " " : "";
67
+ let line = "";
68
+ for (const word of row.text.split(" ")) {
69
+ const next = line ? `${line} ${word}` : word;
70
+ if (line && displayWidth(next) > width) {
71
+ out.push({ ...row, text: line });
72
+ line = indent + word;
73
+ } else line = next;
74
+ }
75
+ if (line) out.push({ ...row, text: line });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ export class GuideBrowser {
81
+ readonly guides: readonly Guide[];
82
+ /** Expanded parents in the tree. */
83
+ readonly expanded = new Set<string>();
84
+ /** Highlighted guide id in the tree. */
85
+ selected: string;
86
+ query = "";
87
+ filtering = false;
88
+ /** The guide open as a page, or undefined in the tree. */
89
+ page: string | undefined;
90
+ scroll = 0;
91
+
92
+ constructor(guides: readonly Guide[], open?: string) {
93
+ this.guides = guides;
94
+ this.selected = guides[0]?.id ?? "";
95
+ if (open) this.open(open);
96
+ }
97
+
98
+ /** Open a guide by id or title word (`/guide chords`); false if unknown. */
99
+ open(name: string): boolean {
100
+ const word = name.trim().toLowerCase();
101
+ const guide =
102
+ this.guides.find((g) => g.id === word) ??
103
+ this.guides.find((g) => g.title.toLowerCase().startsWith(word)) ??
104
+ this.guides.find((g) => g.id.includes(word));
105
+ if (!guide) return false;
106
+ for (let p = guide.parent; p; p = this.byId(p)?.parent)
107
+ this.expanded.add(p);
108
+ this.selected = guide.id;
109
+ this.page = guide.id;
110
+ this.scroll = 0;
111
+ return true;
112
+ }
113
+
114
+ get typing(): boolean {
115
+ return this.page === undefined && this.filtering;
116
+ }
117
+
118
+ private byId(id: string): Guide | undefined {
119
+ return this.guides.find((g) => g.id === id);
120
+ }
121
+
122
+ private children(id: string | undefined): Guide[] {
123
+ return this.guides.filter((g) => g.parent === id);
124
+ }
125
+
126
+ /** Guides matching the filter, plus their ancestors; all when no filter. */
127
+ private matches(): Set<string> | undefined {
128
+ const query = this.query.trim().toLowerCase();
129
+ if (!query) return undefined;
130
+ const keep = new Set<string>();
131
+ for (const guide of this.guides) {
132
+ const text = `${guide.title}\n${guide.body}`.toLowerCase();
133
+ if (!text.includes(query)) continue;
134
+ for (let g: Guide | undefined = guide; g; g = this.byId(g.parent ?? ""))
135
+ keep.add(g.id);
136
+ }
137
+ return keep;
138
+ }
139
+
140
+ /** The tree rows shown now, in order, with their depth. */
141
+ visible(): { guide: Guide; depth: number }[] {
142
+ const keep = this.matches();
143
+ const out: { guide: Guide; depth: number }[] = [];
144
+ const visit = (parent: string | undefined, depth: number) => {
145
+ for (const guide of this.children(parent)) {
146
+ if (keep && !keep.has(guide.id)) continue;
147
+ out.push({ guide, depth });
148
+ if (keep || this.expanded.has(guide.id)) visit(guide.id, depth + 1);
149
+ }
150
+ };
151
+ visit(undefined, 0);
152
+ return out;
153
+ }
154
+
155
+ private move(step: number): void {
156
+ const rows = this.visible();
157
+ if (rows.length === 0) return;
158
+ const at = Math.max(
159
+ 0,
160
+ rows.findIndex((row) => row.guide.id === this.selected),
161
+ );
162
+ const next = Math.max(0, Math.min(rows.length - 1, at + step));
163
+ this.selected = rows[next]!.guide.id;
164
+ }
165
+
166
+ /** Keep the selection on a visible row after the filter changes. */
167
+ private settle(): void {
168
+ const rows = this.visible();
169
+ if (!rows.some((row) => row.guide.id === this.selected))
170
+ this.selected = rows[0]?.guide.id ?? this.selected;
171
+ }
172
+
173
+ private openSelected(): void {
174
+ if (this.visible().some((row) => row.guide.id === this.selected)) {
175
+ this.page = this.selected;
176
+ this.scroll = 0;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * One key. `close` means Esc at the top level; `handled` that it changed
182
+ * (or deliberately swallowed) something; `ignored` that it was not ours.
183
+ * `pageRows` is the page height, for pgup/pgdn and clamping.
184
+ */
185
+ key(value: string, pageRows = 10): "handled" | "close" | "ignored" {
186
+ const nav = overlayKey(value);
187
+ const left = value === "\u001b[D" || value === "\u001bOD";
188
+ const right = value === "\u001b[C" || value === "\u001bOC";
189
+ const esc = value === "\u001b";
190
+ if (this.page !== undefined) {
191
+ const total = this.pageLength();
192
+ const max = Math.max(0, total - pageRows);
193
+ const step =
194
+ nav === "down" || value === "j"
195
+ ? 1
196
+ : nav === "up" || value === "k"
197
+ ? -1
198
+ : nav === "pgdn" || value === " "
199
+ ? pageRows
200
+ : nav === "pgup"
201
+ ? -pageRows
202
+ : nav === "home"
203
+ ? -total
204
+ : nav === "end"
205
+ ? total
206
+ : 0;
207
+ if (step !== 0) {
208
+ this.scroll = Math.max(0, Math.min(max, this.scroll + step));
209
+ return "handled";
210
+ }
211
+ if (esc || left || value === "h" || value === "\u007f") {
212
+ this.page = undefined;
213
+ this.scroll = 0;
214
+ return "handled";
215
+ }
216
+ return "handled";
217
+ }
218
+ if (this.filtering) {
219
+ if (esc) {
220
+ this.query = "";
221
+ this.filtering = false;
222
+ this.settle();
223
+ return "handled";
224
+ }
225
+ if (value === "\u007f" || value === "\b") {
226
+ this.query = this.query.slice(0, -1);
227
+ if (!this.query) this.filtering = false;
228
+ this.settle();
229
+ return "handled";
230
+ }
231
+ if (nav === "enter") {
232
+ this.filtering = false;
233
+ this.openSelected();
234
+ return "handled";
235
+ }
236
+ if (nav === "up" || nav === "down") {
237
+ this.filtering = false;
238
+ this.move(nav === "up" ? -1 : 1);
239
+ return "handled";
240
+ }
241
+ if (!value.startsWith("\u001b") && value >= " " && value.length <= 4) {
242
+ this.query = (this.query + value).slice(0, 40);
243
+ this.settle();
244
+ return "handled";
245
+ }
246
+ return "handled";
247
+ }
248
+ if (nav === "up" || value === "k") return this.moved(-1);
249
+ if (nav === "down" || value === "j") return this.moved(1);
250
+ if (nav === "pgup") return this.moved(-pageRows);
251
+ if (nav === "pgdn") return this.moved(pageRows);
252
+ if (nav === "home") return this.moved(-this.guides.length);
253
+ if (nav === "end") return this.moved(this.guides.length);
254
+ if (value === "/") {
255
+ this.filtering = true;
256
+ return "handled";
257
+ }
258
+ const hasChildren = this.children(this.selected).length > 0;
259
+ const open = this.query !== "" || this.expanded.has(this.selected);
260
+ if (right || value === "l" || nav === "enter") {
261
+ if (hasChildren && !open) this.expanded.add(this.selected);
262
+ else this.openSelected();
263
+ return "handled";
264
+ }
265
+ if (left || value === "h") {
266
+ if (hasChildren && this.expanded.has(this.selected) && !this.query)
267
+ this.expanded.delete(this.selected);
268
+ else {
269
+ const parent = this.byId(this.selected)?.parent;
270
+ if (parent) this.selected = parent;
271
+ }
272
+ return "handled";
273
+ }
274
+ if (esc) {
275
+ if (this.query) {
276
+ this.query = "";
277
+ this.settle();
278
+ return "handled";
279
+ }
280
+ return "close";
281
+ }
282
+ return "ignored";
283
+ }
284
+
285
+ private moved(step: number): "handled" {
286
+ this.move(step);
287
+ return "handled";
288
+ }
289
+
290
+ private pageWidth = 72;
291
+
292
+ private pageLength(): number {
293
+ const guide = this.byId(this.page ?? "");
294
+ return guide ? wrapRows(guideLines(guide.body), this.pageWidth).length : 0;
295
+ }
296
+
297
+ /** What to paint in a pane `width` columns wide and `height` rows tall. */
298
+ view(width: number, height: number, unicode = true): GuideView {
299
+ this.pageWidth = width;
300
+ const page = this.page === undefined ? undefined : this.byId(this.page);
301
+ if (page) {
302
+ const rows = wrapRows(guideLines(page.body, unicode), width);
303
+ const max = Math.max(0, rows.length - height);
304
+ this.scroll = Math.max(0, Math.min(max, this.scroll));
305
+ const crumb = page.parent ? `${this.byId(page.parent)?.title} › ` : "";
306
+ return {
307
+ title: `guide · ${crumb}${page.title}`.replace(
308
+ "›",
309
+ unicode ? "›" : ">",
310
+ ),
311
+ rows: rows.slice(this.scroll, this.scroll + height),
312
+ scroll: this.scroll,
313
+ hint: GUIDE_HINTS.page,
314
+ };
315
+ }
316
+ const visible = this.visible();
317
+ const rows: GuideRow[] = visible.map(({ guide, depth }) => {
318
+ const parent = this.children(guide.id).length > 0;
319
+ const open = this.query !== "" || this.expanded.has(guide.id);
320
+ const marker = parent
321
+ ? open
322
+ ? unicode
323
+ ? "▾"
324
+ : "v"
325
+ : unicode
326
+ ? "▸"
327
+ : ">"
328
+ : " ";
329
+ return {
330
+ text: `${" ".repeat(depth)}${marker} ${guide.title}`,
331
+ selected: guide.id === this.selected,
332
+ };
333
+ });
334
+ if (rows.length === 0) rows.push({ text: "no guide matches", muted: true });
335
+ const filter = this.filtering || this.query;
336
+ if (filter)
337
+ rows.unshift({
338
+ text: `/ ${this.query}${this.filtering ? "_" : ""}`,
339
+ muted: true,
340
+ });
341
+ const body = Math.max(1, height);
342
+ const at = rows.findIndex((row) => row.selected);
343
+ const scroll = Math.max(0, Math.min(rows.length - body, at - body + 2));
344
+ return {
345
+ title: "guide",
346
+ rows: rows.slice(scroll, scroll + body),
347
+ scroll,
348
+ hint: this.filtering ? GUIDE_HINTS.filtering : GUIDE_HINTS.tree,
349
+ };
350
+ }
351
+ }
package/tui/highway.ts CHANGED
@@ -34,6 +34,16 @@ export interface NoteSnapshot {
34
34
  selected?: boolean | undefined;
35
35
  pending?: boolean | undefined;
36
36
  muted?: boolean | undefined;
37
+ /**
38
+ * Cents the note sounds away from the nearest 12-TET pitch (tuning plus note
39
+ * cents). Drawn as a compact `+14`/`−32` tag beside the approaching head.
40
+ */
41
+ cents?: number | undefined;
42
+ /**
43
+ * In a linear non-12 tuning, the 12-TET pitch class `cents` is measured
44
+ * from (`D` for a `D−47` tag), since the lane is the key, not the sound.
45
+ */
46
+ centsFrom?: string | undefined;
37
47
  }
38
48
 
39
49
  export interface TrackScoreSnapshot {
@@ -45,6 +55,11 @@ export interface TrackScoreSnapshot {
45
55
  bpm?: number | undefined;
46
56
  key?: string | undefined;
47
57
  beatsPerBar?: number | undefined;
58
+ /**
59
+ * Bar starts in beats within the loop when the meter changes (bar 1 at
60
+ * 0); omitted, bars repeat every `beatsPerBar` beats.
61
+ */
62
+ barBeats?: readonly number[] | undefined;
48
63
  loopBeats?: number | undefined;
49
64
  laneCount?: number | undefined;
50
65
  /** Optional lane legend (drum voices) drawn just below the hit line. */
@@ -53,6 +68,11 @@ export interface TrackScoreSnapshot {
53
68
  projection?: LaneProjection | undefined;
54
69
  /** Static transport position when the score is paused. */
55
70
  transportBeat?: number | undefined;
71
+ /**
72
+ * Steps per period and degree-0 key of a linear non-12 tuning; lane labels
73
+ * then mark each period from the root instead of every C.
74
+ */
75
+ tuningPeriod?: { size: number; root: number } | undefined;
56
76
  /** Alias accepted by adapters that call this value currentBeat. */
57
77
  currentBeat?: number | undefined;
58
78
  transportStartedAtMs?: number | undefined;
@@ -128,6 +148,21 @@ const NOTE_NAMES = [
128
148
  "B",
129
149
  ];
130
150
 
151
+ /**
152
+ * `+14`/`−32` for a cents deviation, empty within ±1 cent; with `from` (a
153
+ * linear non-12 tuning) the pitch class leads: `D−47`, or `C` alone.
154
+ */
155
+ export function centsTag(cents: number | undefined, from?: string): string {
156
+ const rounded = Math.round(cents ?? 0);
157
+ const value =
158
+ Math.abs(rounded) < 1 || !Number.isFinite(rounded)
159
+ ? ""
160
+ : rounded > 0
161
+ ? `+${rounded}`
162
+ : `−${-rounded}`;
163
+ return from ? `${from}${value}` : value;
164
+ }
165
+
131
166
  export function pitchName(pitch: number): string {
132
167
  const rounded = Math.round(pitch);
133
168
  return `${NOTE_NAMES[((rounded % 12) + 12) % 12]}${Math.floor(rounded / 12) - 1}`;
@@ -140,6 +175,7 @@ export function pitchName(pitch: number): string {
140
175
  export function pitchProjection(
141
176
  notes: readonly NoteSnapshot[],
142
177
  minimumSpan = 12,
178
+ period?: { size: number; root: number },
143
179
  ): LaneProjection {
144
180
  const pitches = notes
145
181
  .map((note) => note.pitch)
@@ -164,6 +200,16 @@ export function pitchProjection(
164
200
  },
165
201
  label(lane) {
166
202
  const pitch = low + lane;
203
+ if (period) {
204
+ // Mark each period from the root, named as the root plus octaves.
205
+ const offset = pitch - period.root;
206
+ if (offset % period.size !== 0) return undefined;
207
+ const octaves = offset / period.size;
208
+ return pitchName(period.root).replace(
209
+ /-?\d+$/,
210
+ String(Math.floor(period.root / 12) - 1 + octaves),
211
+ );
212
+ }
167
213
  return pitch % 12 === 0 ? pitchName(pitch) : undefined;
168
214
  },
169
215
  };
@@ -187,6 +233,8 @@ export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
187
233
  .flatMap((layer) => layer.notes);
188
234
  return pitchProjection(
189
235
  melodic.length ? [...score.notes, ...melodic] : score.notes,
236
+ 12,
237
+ score.tuningPeriod,
190
238
  );
191
239
  }
192
240
 
@@ -480,15 +528,36 @@ export function paintHighway(
480
528
  !(score.layers ?? []).some((layer) => layer.notes.length > 0);
481
529
 
482
530
  // Beat, bar, and loop rules of increasing strength.
531
+ const barBeats =
532
+ score.barBeats && score.barBeats.length > 0 ? score.barBeats : undefined;
483
533
  for (let row = 0; row <= lastNoteRow; row += 1) {
484
534
  if (row === hitRow) continue;
485
535
  const center = rowBeat(row);
486
536
  const half = 0.5 / rowsPerBeat;
487
- const k = Math.ceil(center - half - 1e-9);
537
+ let k = Math.ceil(center - half - 1e-9);
538
+ // Meter changes: a bar may start between beats (7/8), so the row's
539
+ // bar start wins over the whole beat.
540
+ let barIndex: number | undefined;
541
+ if (barBeats) {
542
+ const base = loop ? Math.floor((center - half) / loop) * loop : 0;
543
+ for (const offset of loop ? [base, base + loop] : [0]) {
544
+ const found = barBeats.findIndex((start) => {
545
+ const at = offset + start;
546
+ return at >= center - half - 1e-9 && at < center + half - 1e-9;
547
+ });
548
+ if (found >= 0) {
549
+ barIndex = found;
550
+ k = offset + barBeats[found]!;
551
+ break;
552
+ }
553
+ }
554
+ }
488
555
  if (k > center + half - 1e-9 || k < 0) continue;
489
556
  const inLoop = loop ? ((k % loop) + loop) % loop : k;
490
557
  const isLoop = loop !== undefined && Math.abs(inLoop) < 1e-9;
491
- const isBar = Math.abs(inLoop % beatsPerBar) < 1e-9;
558
+ const isBar = barBeats
559
+ ? barIndex !== undefined
560
+ : Math.abs(inLoop % beatsPerBar) < 1e-9;
492
561
  const glyph = isLoop
493
562
  ? glyphs.loopRule
494
563
  : isBar
@@ -506,7 +575,11 @@ export function paintHighway(
506
575
  if (gutter > 0 && (isBar || isLoop) && !empty) {
507
576
  const label = isLoop
508
577
  ? glyphs.loop
509
- : String(Math.floor(inLoop / beatsPerBar) + 1);
578
+ : String(
579
+ barIndex !== undefined
580
+ ? barIndex + 1
581
+ : Math.floor(inLoop / beatsPerBar) + 1,
582
+ );
510
583
  const text = label.slice(-(gutter - 1)).padStart(gutter - 1, " ");
511
584
  for (let index = 0; index < text.length; index += 1)
512
585
  painter.put(
@@ -760,7 +833,17 @@ export function paintHighway(
760
833
  : glow > 0.6
761
834
  ? { ...shade(base, 0.45 * glow), bold: true }
762
835
  : shade(base, 0.45 * glow);
763
- if (headRow <= hitRow - 1) tile(headRow, headGlyph, style);
836
+ if (headRow <= hitRow - 1) {
837
+ tile(headRow, headGlyph, style);
838
+ const tag =
839
+ (note.cents !== undefined || note.centsFrom !== undefined) &&
840
+ !note.muted
841
+ ? centsTag(note.cents, note.centsFrom)
842
+ : "";
843
+ if (tag && x + width + tag.length <= region.width)
844
+ for (let index = 0; index < tag.length; index += 1)
845
+ painter.put(x + width + index, headRow, tag[index]!, roles.muted);
846
+ }
764
847
  // The target on the hit line lights up as the note approaches.
765
848
  if (!note.muted && glow > 0 && width > 0) {
766
849
  for (let offset = 0; offset < width; offset += 1)
package/tui/hits.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Click targets, recorded by the same paint code that draws them: every
3
+ * frame rebuilds the list, so hit-testing can never drift from the screen.
4
+ * Later regions sit on top (an overlay paints after what it covers).
5
+ */
6
+
7
+ export type HitTarget =
8
+ /** A row of the open picker (or the menu), by index into its items. */
9
+ | { kind: "picker-row"; index: number }
10
+ /** The header's transport pill (play / pause). */
11
+ | { kind: "transport" }
12
+ /** The header's track name (the track list). */
13
+ | { kind: "tracks" }
14
+ /** The header's model pill (`/model`). */
15
+ | { kind: "model" }
16
+ /** A fader's step buttons, by field index in the drawer. */
17
+ | { kind: "fader-step"; field: number; direction: 1 | -1 }
18
+ /** A fader bar: `x` columns from `left` map onto min…max. */
19
+ | { kind: "fader-bar"; field: number; left: number; width: number }
20
+ /** A fader's row (label and value): focuses that field. */
21
+ | { kind: "fader-row"; field: number }
22
+ /** One option of a choice field. */
23
+ | { kind: "fader-option"; field: number; option: number }
24
+ /** The drawer's keep / revert buttons. */
25
+ | { kind: "fader-keep" }
26
+ | { kind: "fader-revert" }
27
+ /** The piano roll / highway area. */
28
+ | { kind: "highway" }
29
+ /** Scrollable text (`/help`, the transcript, the `?` panel). */
30
+ | { kind: "text" };
31
+
32
+ export interface HitRegion {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ target: HitTarget;
38
+ }
39
+
40
+ export class HitMap {
41
+ readonly regions: HitRegion[] = [];
42
+
43
+ add(
44
+ x: number,
45
+ y: number,
46
+ width: number,
47
+ height: number,
48
+ target: HitTarget,
49
+ ): void {
50
+ if (width <= 0 || height <= 0) return;
51
+ this.regions.push({ x, y, width, height, target });
52
+ }
53
+
54
+ /** The topmost region under a cell, or undefined. */
55
+ at(x: number, y: number): HitRegion | undefined {
56
+ for (let index = this.regions.length - 1; index >= 0; index -= 1) {
57
+ const region = this.regions[index]!;
58
+ if (
59
+ x >= region.x &&
60
+ x < region.x + region.width &&
61
+ y >= region.y &&
62
+ y < region.y + region.height
63
+ )
64
+ return region;
65
+ }
66
+ return undefined;
67
+ }
68
+ }
package/tui/input.ts CHANGED
@@ -53,8 +53,15 @@ export class TerminalInputDecoder {
53
53
  // shared a read with the next key, e.g. Esc then Ctrl+Z.
54
54
  const next = this.buffer.charCodeAt(1);
55
55
  if (next < 0x20 || next === 0x7f) return "\u001b";
56
+ // A legacy X10 mouse report (a terminal without SGR 1006): CSI M and
57
+ // three raw bytes. One event, so its bytes never type into the prompt.
58
+ if (this.buffer.startsWith("\u001b[M"))
59
+ return this.buffer.length >= 6 ? this.buffer.slice(0, 6) : undefined;
56
60
  const final = this.buffer.match(/^\u001b\[[\x20-?]*[\x40-~]/)?.[0];
57
61
  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;
58
65
  // An unrecognised Alt-prefixed character is still one complete event.
59
66
  if (this.buffer.length >= 2 && this.buffer[1] !== "[")
60
67
  return this.buffer.slice(0, 2);
package/tui/keys.ts CHANGED
@@ -100,3 +100,75 @@ const OVERLAY_SEQUENCES: Record<string, OverlayKey> = {
100
100
  export function overlayKey(value: string): OverlayKey | undefined {
101
101
  return OVERLAY_SEQUENCES[value];
102
102
  }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Mouse (SGR 1006)
106
+
107
+ /**
108
+ * Mouse reporting: 1000 clicks, 1002 drags with a button held, 1006 the
109
+ * SGR encoding (`ESC [ < b ; x ; y M|m`), which has no 223-column limit and
110
+ * tells a release from a press. Off is the same modes in reverse order.
111
+ */
112
+ export const MOUSE_ON = "\u001b[?1000h\u001b[?1002h\u001b[?1006h";
113
+ export const MOUSE_OFF = "\u001b[?1006l\u001b[?1002l\u001b[?1000l";
114
+
115
+ export type MouseButton = "left" | "middle" | "right" | "none";
116
+
117
+ export interface MouseEvent {
118
+ kind: "down" | "up" | "drag" | "move" | "wheel";
119
+ button: MouseButton;
120
+ /** 0-based cell column and row. */
121
+ x: number;
122
+ y: number;
123
+ /** For `wheel`: -1 up (away from you), 1 down. */
124
+ delta: -1 | 1 | 0;
125
+ shift: boolean;
126
+ alt: boolean;
127
+ ctrl: boolean;
128
+ }
129
+
130
+ const SGR_MOUSE = /^\u001b\[<(\d{1,4});(\d{1,5});(\d{1,5})([Mm])$/;
131
+ const BUTTONS: readonly MouseButton[] = ["left", "middle", "right", "none"];
132
+
133
+ /** Decode one SGR mouse report; undefined for anything else. */
134
+ export function parseMouse(value: string): MouseEvent | undefined {
135
+ const match = SGR_MOUSE.exec(value);
136
+ if (!match) return undefined;
137
+ const code = Number(match[1]);
138
+ const x = Number(match[2]) - 1;
139
+ const y = Number(match[3]) - 1;
140
+ if (x < 0 || y < 0) return undefined;
141
+ const modifiers = {
142
+ shift: (code & 4) !== 0,
143
+ alt: (code & 8) !== 0,
144
+ ctrl: (code & 16) !== 0,
145
+ };
146
+ const button = BUTTONS[code & 3]!;
147
+ if (code & 64) {
148
+ // 64/65 wheel up/down; 66/67 (horizontal wheels) are ignored.
149
+ if ((code & 3) > 1) return undefined;
150
+ return {
151
+ kind: "wheel",
152
+ button: "none",
153
+ x,
154
+ y,
155
+ delta: (code & 1) === 0 ? -1 : 1,
156
+ ...modifiers,
157
+ };
158
+ }
159
+ const motion = (code & 32) !== 0;
160
+ const kind =
161
+ match[4] === "m"
162
+ ? "up"
163
+ : motion
164
+ ? button === "none"
165
+ ? "move"
166
+ : "drag"
167
+ : "down";
168
+ return { kind, button, x, y, delta: 0, ...modifiers };
169
+ }
170
+
171
+ /** True for any mouse report, SGR or a legacy X10 one (dropped). */
172
+ export function isMouseSequence(value: string): boolean {
173
+ return value.startsWith("\u001b[<") || value.startsWith("\u001b[M");
174
+ }