@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
package/tui/drums.ts CHANGED
@@ -3,6 +3,11 @@
3
3
  * (kick, snare, clap, rim, tom, hat, open hat) instead of by pitch.
4
4
  */
5
5
  import { DRUM_VOICES, drumLane, isDrumInstrument } from "../core/drums.ts";
6
+ import {
7
+ isSamplerInstrument,
8
+ samplerVoiceSlots,
9
+ type Track,
10
+ } from "../core/score.ts";
6
11
  import type { NoteSnapshot, TrackScoreSnapshot } from "./render.ts";
7
12
 
8
13
  export function drumSnapshotFields(
@@ -19,3 +24,42 @@ export function drumSnapshotFields(
19
24
  })),
20
25
  };
21
26
  }
27
+
28
+ /**
29
+ * Sampler adapter: oneshot samplers stream one lane per voice (named, in
30
+ * pitch-slot order from 36); keyed samplers keep the pitch axis, labelled by
31
+ * note name like any melodic track.
32
+ */
33
+ export function samplerSnapshotFields(
34
+ track: Pick<Track, "instrument" | "sampler"> | undefined,
35
+ notes: readonly NoteSnapshot[],
36
+ ): Partial<TrackScoreSnapshot> {
37
+ const lanes = samplerLanes(track);
38
+ if (!lanes) return {};
39
+ return {
40
+ laneCount: lanes.labels.length,
41
+ laneLabels: lanes.labels,
42
+ notes: notes.flatMap((note) => {
43
+ const lane = lanes.laneOf(note.pitch ?? -1);
44
+ return lane === undefined ? [] : [{ ...note, lane }];
45
+ }),
46
+ };
47
+ }
48
+
49
+ /** Oneshot sampler lanes, or undefined for keyed/non-sampler tracks. */
50
+ export function samplerLanes(
51
+ track: Pick<Track, "instrument" | "sampler"> | undefined,
52
+ ):
53
+ | { labels: string[]; laneOf: (pitch: number) => number | undefined }
54
+ | undefined {
55
+ if (!track || !isSamplerInstrument(track.instrument) || !track.sampler)
56
+ return undefined;
57
+ if (track.sampler.mode !== "oneshot") return undefined;
58
+ const slots = [...samplerVoiceSlots(track.sampler)];
59
+ if (slots.length === 0) return undefined;
60
+ const byPitch = new Map(slots.map(([, slot], index) => [slot, index]));
61
+ return {
62
+ labels: slots.map(([voice]) => voice),
63
+ laneOf: (pitch) => byPitch.get(pitch),
64
+ };
65
+ }
package/tui/grammar.ts ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The shared key grammar: one vocabulary for every picker, editor and menu,
3
+ * the one-line footer hint each screen shows, and the `?` panel listing the
4
+ * keys of the current screen.
5
+ *
6
+ * ↑↓ (j k) move Enter open or confirm
7
+ * ←→ (h l, - +) adjust a value Space audition or toggle
8
+ * / filter Esc back one level (filter first)
9
+ * ? keys for this screen
10
+ * digits type a value, only where a value is focused
11
+ *
12
+ * Play mode is the one exception (its letters are piano keys, by the
13
+ * GarageBand "Musical Typing" convention); its panel says so.
14
+ */
15
+ import { displayWidth } from "./text.ts";
16
+
17
+ /** A `key action` pair for the `?` panel. */
18
+ export type KeyRow = readonly [keys: string, action: string];
19
+ export type KeySection = Readonly<{ title: string; rows: readonly KeyRow[] }>;
20
+
21
+ /**
22
+ * Fit a ` a · b · esc back · ? keys ` hint into `width` columns by dropping
23
+ * parts from the middle: the way out (`esc …`) and `? keys` stay longest,
24
+ * since they are how a user leaves or learns the rest. Returns "" when not
25
+ * even `? keys` fits.
26
+ */
27
+ export function fitHint(hint: string, width: number): string {
28
+ const key = `${width}\u0000${hint}`;
29
+ const cached = fitted.get(key);
30
+ if (cached !== undefined) return cached;
31
+ const text = fitHintUncached(hint, width);
32
+ if (fitted.size > 256) fitted.clear();
33
+ fitted.set(key, text);
34
+ return text;
35
+ }
36
+
37
+ /** Hints repeat every frame; fit each (hint, width) once. */
38
+ const fitted = new Map<string, string>();
39
+
40
+ function fitHintUncached(hint: string, width: number): string {
41
+ const parts = hint
42
+ .trim()
43
+ .split(" · ")
44
+ .filter((part) => part.length > 0);
45
+ if (parts.length === 0) return "";
46
+ const tailAt = parts.findIndex(
47
+ (part) => part.startsWith("esc ") || part.startsWith("? "),
48
+ );
49
+ const head = tailAt < 0 ? parts.slice(0, -1) : parts.slice(0, tailAt);
50
+ const tail = tailAt < 0 ? parts.slice(-1) : parts.slice(tailAt);
51
+ for (;;) {
52
+ const text = ` ${[...head, ...tail].join(" · ")} `;
53
+ if (displayWidth(text) <= width) return text;
54
+ if (head.length > 0) head.pop();
55
+ else if (tail.length > 1) tail.shift();
56
+ else return "";
57
+ }
58
+ }
59
+
60
+ /** The ASCII spelling of a hint, for terminals without Unicode arrows. */
61
+ export function asciiHint(hint: string): string {
62
+ return hint
63
+ .replace(/↑↓/g, "up/dn")
64
+ .replace(/←→/g, "lt/rt")
65
+ .replace(/–/g, "-");
66
+ }
67
+
68
+ // ── footer hints, one per screen ──────────────────────────────────────
69
+
70
+ export const HINTS = {
71
+ list: " ↑↓ move · enter choose · / filter · esc back · ? keys ",
72
+ preview: " ↑↓ preview · enter apply · / filter · esc back · ? keys ",
73
+ hover: " ↑↓ hear · enter choose · a A/B · c context · esc back · ? keys ",
74
+ audition:
75
+ " ↑↓ hear · enter keep · space loop · a A/B · c mix · / filter · esc back · ? keys ",
76
+ filtering: " type to filter · enter choose · esc clear · ? keys ",
77
+ menu: " ↑↓ move · enter open · / filter · esc back · ? keys ",
78
+ value: " ←→ adjust · enter type · x reset · esc back · ? keys ",
79
+ point: " ←→ adjust · enter type · x delete · esc back · ? keys ",
80
+ action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
81
+ typing: " type a value · enter apply · esc cancel ",
82
+ euclid:
83
+ " ↑↓ voice · ←→ adjust · tab field · space hear · x off · esc back · ? keys ",
84
+ text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
85
+ log: " ↑↓ scroll · / filter · esc back · ? keys ",
86
+ keys: " any key closes ",
87
+ } as const;
88
+
89
+ // ── the `?` panel ─────────────────────────────────────────────────────
90
+
91
+ const LIST: readonly KeyRow[] = [
92
+ ["↑ ↓ j k", "move"],
93
+ ["pgup pgdn", "page"],
94
+ ["enter", "choose"],
95
+ ["/", "filter (type, then enter or esc)"],
96
+ ["esc", "clear the filter, then back"],
97
+ ];
98
+
99
+ export const KEYS = {
100
+ prompt: [
101
+ {
102
+ title: "start here",
103
+ rows: [
104
+ ["type + enter", "ask for a change in plain words, or a command"],
105
+ ["ctrl-p", "play notes on the keyboard (chords: q)"],
106
+ ["ctrl-k", "menu: sound, effects, rhythm, chords, mix"],
107
+ ["space", "play / pause (empty prompt)"],
108
+ ["/help", "what dawg can do · /help <topic> for more"],
109
+ ],
110
+ },
111
+ {
112
+ title: "prompt",
113
+ rows: [
114
+ ["enter", "send"],
115
+ ["shift-enter ctrl-j", "new line"],
116
+ ["alt-enter ctrl-q", "queue after the current request"],
117
+ ["ctrl-z ctrl-y", "undo / redo"],
118
+ ["ctrl-o", "transcript"],
119
+ ["esc", "cancel the agent · close a panel"],
120
+ ["ctrl-c", "quit"],
121
+ ],
122
+ },
123
+ ],
124
+ list: [{ title: "list", rows: LIST }],
125
+ audition: [
126
+ {
127
+ title: "auditioning list",
128
+ rows: [
129
+ ["space", "loop the focused track · again stops"],
130
+ ["↑ ↓ j k", "move; while looping, hear the row on the loop"],
131
+ ["a", "A/B: before ↔ the highlighted row"],
132
+ ["c", "solo ↔ in context (the whole mix)"],
133
+ ["enter", "keep it (one undo step)"],
134
+ ["esc", "back; nothing changes"],
135
+ ["/", "filter (type, then enter or esc)"],
136
+ ],
137
+ },
138
+ ],
139
+ preview: [
140
+ {
141
+ title: "patterns",
142
+ rows: [
143
+ ["↑ ↓ j k", "move and hear the pattern"],
144
+ ["enter", "apply it to the focused track"],
145
+ ...LIST.slice(3),
146
+ ],
147
+ },
148
+ ],
149
+ menu: [
150
+ {
151
+ title: "menu",
152
+ rows: [
153
+ ["↑ ↓ j k", "move"],
154
+ ["enter → l", "open a section"],
155
+ ["esc ← h", "back one level"],
156
+ ["/", "filter this level"],
157
+ ],
158
+ },
159
+ {
160
+ title: "on a value",
161
+ rows: [
162
+ ["← → h l - +", "adjust"],
163
+ ["0-9", "type a value, enter applies"],
164
+ ["enter", "type a value · pick from a list"],
165
+ ["space", "toggle on/off (elsewhere: hear the track)"],
166
+ ["x delete", "reset to default (deletes an automation point)"],
167
+ ],
168
+ },
169
+ {
170
+ title: "auditioning",
171
+ rows: [
172
+ ["space", "loop the focused track · again stops"],
173
+ ["c", "solo ↔ in context (the whole mix)"],
174
+ ["a", "A/B: committed ↔ staged"],
175
+ ["enter", "keep staged changes (one undo step)"],
176
+ ["esc", "revert staged changes"],
177
+ ],
178
+ },
179
+ {
180
+ title: "every change",
181
+ rows: [
182
+ ["", "while looping, stages; otherwise runs the command shown"],
183
+ ["ctrl-z", "undoes it"],
184
+ ],
185
+ },
186
+ ],
187
+ euclid: [
188
+ {
189
+ title: "rhythm editor",
190
+ rows: [
191
+ ["↑ ↓ j k", "voice"],
192
+ ["← → h l - +", "adjust the field"],
193
+ ["tab shift-tab ] [", "next / previous field"],
194
+ ["0-9", "type a value, enter applies"],
195
+ ["enter", "add a row (or type a value)"],
196
+ ["space", "hear the voice"],
197
+ ["x", "turn the row off"],
198
+ ["f", "freeze into plain hits"],
199
+ ["esc", "back"],
200
+ ],
201
+ },
202
+ ],
203
+ text: [
204
+ {
205
+ title: "panel",
206
+ rows: [
207
+ ["↑ ↓ j k", "scroll"],
208
+ ["pgup pgdn home end", "page"],
209
+ ["esc", "close"],
210
+ ],
211
+ },
212
+ ],
213
+ log: [
214
+ {
215
+ title: "transcript",
216
+ rows: [
217
+ ["↑ ↓", "scroll"],
218
+ ["pgup pgdn home end", "page"],
219
+ ["/", "next filter: all, requests, ops, errors"],
220
+ ["esc ctrl-o", "close"],
221
+ ],
222
+ },
223
+ ],
224
+ play: [
225
+ {
226
+ title: "play mode · letters are piano keys",
227
+ rows: [
228
+ ["a s d f g h j k l ; '", "white keys"],
229
+ ["w e t y u o p", "black keys"],
230
+ ["z x", "octave down / up"],
231
+ ["c v", "velocity down / up"],
232
+ ["shift · tab", "sustain while held · sustain latch"],
233
+ ["space", "play / pause (with count-in)"],
234
+ ["r · R", "record · record replacing"],
235
+ ["m", "metronome click"],
236
+ ["q", "chord mode: auto ⇄ manual"],
237
+ ["/", "type a command, still in play mode"],
238
+ ["ctrl-k", "menu"],
239
+ ["esc", "leave play mode"],
240
+ ],
241
+ },
242
+ ],
243
+ chords: [
244
+ {
245
+ title: "chord mode · number row latches",
246
+ rows: [
247
+ ["1 2 3 4", "dim · min · maj · sus (two combine)"],
248
+ ["5 6 7 8", "add 6 · m7 · M7 · 9"],
249
+ ["0", "clear the latches"],
250
+ ["- =", "voicing down / up"],
251
+ ["9", "next perform mode (block, strum, arp…)"],
252
+ ["b", "next bass mode"],
253
+ ["n", "play the suggested next chord"],
254
+ ["q", "auto (chords fit the key) ⇄ manual"],
255
+ ],
256
+ },
257
+ ],
258
+ } satisfies Record<string, readonly KeySection[]>;
259
+
260
+ export type KeyScreen = keyof typeof KEYS;
261
+
262
+ /** `?` panel lines: a heading per section, then aligned `keys action`. */
263
+ export function keyLines(sections: readonly KeySection[]): string[] {
264
+ const column =
265
+ Math.max(
266
+ 0,
267
+ ...sections.flatMap((section) =>
268
+ section.rows.map(([keys]) => displayWidth(keys)),
269
+ ),
270
+ ) + 2;
271
+ const lines: string[] = [];
272
+ for (const section of sections) {
273
+ if (lines.length) lines.push("");
274
+ lines.push(`── ${section.title}`);
275
+ for (const [keys, action] of section.rows)
276
+ lines.push(
277
+ `${keys}${" ".repeat(Math.max(1, column - displayWidth(keys)))}${action}`,
278
+ );
279
+ }
280
+ return lines;
281
+ }
package/tui/highway.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { CellBuffer } from "./screen.ts";
14
+ import { truncate } from "./text.ts";
14
15
  import {
15
16
  accentStyle,
16
17
  onBackground,
@@ -472,6 +473,11 @@ export function paintHighway(
472
473
  const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
473
474
  const legendRow = layout.legendRow;
474
475
  const lastNoteRow = (legendRow ?? region.height) - 1;
476
+ // Nothing to label yet: bar numbers and the lane legend stay off and a
477
+ // centred hint says how to start. The hit line and grid still draw.
478
+ const empty =
479
+ score.notes.length === 0 &&
480
+ !(score.layers ?? []).some((layer) => layer.notes.length > 0);
475
481
 
476
482
  // Beat, bar, and loop rules of increasing strength.
477
483
  for (let row = 0; row <= lastNoteRow; row += 1) {
@@ -497,7 +503,7 @@ export function paintHighway(
497
503
  if (!isBar && !isLoop && (column - gutter) % 2 === 1) continue;
498
504
  painter.put(column, row, glyph, style);
499
505
  }
500
- if (gutter > 0 && (isBar || isLoop)) {
506
+ if (gutter > 0 && (isBar || isLoop) && !empty) {
501
507
  const label = isLoop
502
508
  ? glyphs.loop
503
509
  : String(Math.floor(inLoop / beatsPerBar) + 1);
@@ -553,8 +559,24 @@ export function paintHighway(
553
559
  }
554
560
  }
555
561
 
562
+ if (empty) {
563
+ const name = score.trackName ?? score.trackId ?? "track";
564
+ const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
565
+ // First run: name the three ways in (ask, play, menu); narrow
566
+ // windows fall back to one prompt command.
567
+ const full = `${name} · empty · type a request · ctrl-p play · ctrl-k menu`;
568
+ const text = truncate(
569
+ full.length <= areaWidth ? full : `${name} · empty · ${start} to start`,
570
+ areaWidth,
571
+ );
572
+ const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
573
+ const y = Math.max(0, Math.floor(hitRow / 2));
574
+ for (let index = 0; index < text.length; index += 1)
575
+ painter.put(x + index, y, text[index]!, roles.muted);
576
+ }
577
+
556
578
  // Legend for drum voices (or C-octave markers for pitch lanes).
557
- if (legendRow !== undefined && projection.label) {
579
+ if (legendRow !== undefined && projection.label && !empty) {
558
580
  let nextFree = 0;
559
581
  for (let lane = 0; lane < layout.laneCount; lane += 1) {
560
582
  const label = projection.label(lane);
package/tui/layers.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  MAX_LAYER_NOTES,
10
10
  type HighwayLayer,
11
11
  } from "./highway.ts";
12
+ import { samplerLanes } from "./drums.ts";
13
+ import type { Sampler } from "../core/score.ts";
12
14
 
13
15
  export interface LayerTrack {
14
16
  id: string;
@@ -16,6 +18,7 @@ export interface LayerTrack {
16
18
  instrument: string;
17
19
  muted: boolean;
18
20
  solo?: boolean | undefined;
21
+ sampler?: Sampler | undefined;
19
22
  }
20
23
 
21
24
  export interface LayerNote {
@@ -43,17 +46,24 @@ export function highwayLayers(
43
46
  let budget = MAX_LAYER_NOTES;
44
47
  return audible.map((track) => {
45
48
  const drum = isDrumInstrument(track.instrument);
49
+ const sampled = drum ? undefined : samplerLanes(track);
46
50
  const own = [];
47
51
  for (const note of notes) {
48
52
  if (note.trackId !== track.id) continue;
49
53
  if (budget <= 0) break;
54
+ const lane = drum
55
+ ? drumLane(note.pitch)
56
+ : sampled
57
+ ? sampled.laneOf(note.pitch)
58
+ : undefined;
59
+ if (sampled && lane === undefined) continue;
50
60
  budget -= 1;
51
61
  own.push({
52
62
  startBeat: note.startTick / tpb,
53
63
  durationBeats: note.durationTicks / tpb,
54
64
  pitch: note.pitch,
55
65
  velocity: note.velocity,
56
- ...(drum ? { lane: drumLane(note.pitch) } : {}),
66
+ ...(lane !== undefined ? { lane } : {}),
57
67
  });
58
68
  }
59
69
  return {
@@ -62,7 +72,9 @@ export function highwayLayers(
62
72
  notes: own,
63
73
  projection: drum
64
74
  ? drumLaneProjection(DRUM_VOICES.map((info) => info.label))
65
- : undefined,
75
+ : sampled
76
+ ? drumLaneProjection(sampled.labels)
77
+ : undefined,
66
78
  };
67
79
  });
68
80
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Play mode's two fixed rows: the header (`PLAY C3–F4 vel 100 ● REC
3
+ * click ✓` plus a beat flash) and a one-line keyboard strip with the keys
4
+ * that are sounding lit. Both repaint in place every frame, so playing never
5
+ * scrolls the transcript.
6
+ */
7
+ import type { CellBuffer } from "./screen.ts";
8
+ import { displayWidth, truncate } from "./text.ts";
9
+ import { onBackground, type Theme } from "./theme.ts";
10
+
11
+ export type PlayStripKey = Readonly<{
12
+ key: string;
13
+ label: string;
14
+ black: boolean;
15
+ lit: boolean;
16
+ }>;
17
+
18
+ export type PlayHeaderView = Readonly<{
19
+ /** `C3–F4`. */
20
+ range: string;
21
+ velocity: number;
22
+ /** Record armed. */
23
+ armed: boolean;
24
+ /** Recording right now (armed and the transport is running). */
25
+ recording: boolean;
26
+ replace: boolean;
27
+ click: boolean;
28
+ sustain: boolean;
29
+ /** `count-in 3` while counting in. */
30
+ countIn?: string | undefined;
31
+ /** Beat within the bar (1-based) and whether it is the flash window. */
32
+ beat?: Readonly<{ index: number; of: number; flash: boolean }> | undefined;
33
+ /** `grid 1/16`. */
34
+ grid: string;
35
+ /** Chord mode: `AUTO C major · Dm (ii) → G · arp-up`, empty when off. */
36
+ chords?: string | undefined;
37
+ /** Short status (`octave C2`, `no audio`). */
38
+ status?: string | undefined;
39
+ keys: readonly PlayStripKey[];
40
+ /** Chord mode's number-row legend; latched entries are `on`. */
41
+ legend?: readonly ChordLegendCell[] | undefined;
42
+ }>;
43
+
44
+ /** One chord-mode key on the legend row: `1 dim`, `9 strum-up`. */
45
+ export type ChordLegendCell = Readonly<{
46
+ key: string;
47
+ label: string;
48
+ on: boolean;
49
+ }>;
50
+
51
+ /** Text of the header row, for tests and narrow terminals. */
52
+ export function playHeaderText(view: PlayHeaderView, unicode = true): string {
53
+ const parts = [
54
+ "PLAY",
55
+ view.range,
56
+ view.armed
57
+ ? `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`
58
+ : "",
59
+ view.click ? "click" : "",
60
+ view.sustain ? "SUSTAIN" : "",
61
+ view.chords ?? "",
62
+ view.countIn ?? "",
63
+ view.status ?? "",
64
+ ].filter(Boolean);
65
+ return parts.join(" ");
66
+ }
67
+
68
+ export function paintPlayHeader(
69
+ buffer: CellBuffer,
70
+ y: number,
71
+ width: number,
72
+ view: PlayHeaderView,
73
+ theme: Theme,
74
+ unicode: boolean,
75
+ ): void {
76
+ const roles = theme.roles;
77
+ const panel = roles.panel;
78
+ buffer.fill(0, y, width, 1, panel);
79
+ let x = 1;
80
+ const put = (text: string, style = roles.text) => {
81
+ if (x >= width - 1) return;
82
+ x += buffer.text(
83
+ x,
84
+ y,
85
+ truncate(text, Math.max(1, width - x - 1)),
86
+ onBackground(style, panel),
87
+ );
88
+ x += buffer.text(x, y, " ", panel);
89
+ };
90
+ put(" PLAY ", roles.pillSteer);
91
+ put(view.range, { ...roles.text, bold: true });
92
+ if (view.armed)
93
+ put(
94
+ `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`,
95
+ view.recording ? roles.error : roles.warning,
96
+ );
97
+ // Velocity, grid and click details live in the `?` panel.
98
+ if (view.click) put("click", roles.success);
99
+ if (view.sustain) put("SUSTAIN", roles.pillQueue);
100
+ if (view.chords) put(view.chords, roles.hit);
101
+ if (view.countIn) put(view.countIn, roles.warning);
102
+ if (view.beat) {
103
+ const cells = Array.from({ length: view.beat.of }, (_, index) =>
104
+ index + 1 === view.beat!.index
105
+ ? unicode
106
+ ? "●"
107
+ : "o"
108
+ : unicode
109
+ ? "·"
110
+ : ".",
111
+ ).join("");
112
+ put(
113
+ cells,
114
+ view.beat.flash
115
+ ? view.beat.index === 1
116
+ ? roles.error
117
+ : roles.hit
118
+ : roles.muted,
119
+ );
120
+ }
121
+ // The way out and the way to learn more, always at the right edge.
122
+ const hint = "? keys · esc leave";
123
+ const right = width - 1 - displayWidth(hint);
124
+ // Status shows whole or by its first clause, never cut mid-word.
125
+ if (view.status) {
126
+ const room = right - 2 - x;
127
+ const first = view.status.split(" · ")[0]!;
128
+ const text =
129
+ displayWidth(view.status) <= room
130
+ ? view.status
131
+ : displayWidth(first) <= room
132
+ ? first
133
+ : "";
134
+ if (text) x += buffer.text(x, y, text, onBackground(roles.muted, panel));
135
+ }
136
+ if (right > x) buffer.text(right, y, hint, onBackground(roles.faint, panel));
137
+ }
138
+
139
+ /** Chord mode's number row: `1 dim 2 min … 9 block b bass off n next`. */
140
+ export function paintChordLegend(
141
+ buffer: CellBuffer,
142
+ y: number,
143
+ width: number,
144
+ cells: readonly ChordLegendCell[],
145
+ theme: Theme,
146
+ ): void {
147
+ const roles = theme.roles;
148
+ buffer.fill(0, y, width, 1, roles.canvas);
149
+ let x = 1;
150
+ for (const cell of cells) {
151
+ const keyWidth = displayWidth(cell.key);
152
+ const cellWidth = keyWidth + 1 + displayWidth(cell.label) + 2;
153
+ if (x + cellWidth > width) break;
154
+ buffer.text(x, y, cell.key, { ...roles.text, bold: true });
155
+ buffer.text(
156
+ x + keyWidth + 1,
157
+ y,
158
+ cell.label,
159
+ cell.on ? { ...roles.hit, reverse: true } : roles.muted,
160
+ );
161
+ x += cellWidth;
162
+ }
163
+ }
164
+
165
+ /** `a C3 │ w C#3 …`: each key with its note; lit keys reversed. */
166
+ export function paintPlayStrip(
167
+ buffer: CellBuffer,
168
+ y: number,
169
+ width: number,
170
+ keys: readonly PlayStripKey[],
171
+ theme: Theme,
172
+ ): void {
173
+ const roles = theme.roles;
174
+ buffer.fill(0, y, width, 1, roles.canvas);
175
+ let x = 1;
176
+ for (const key of keys) {
177
+ const cell = `${key.key.toUpperCase()} ${key.label}`;
178
+ const cellWidth = displayWidth(cell) + 1;
179
+ if (x + cellWidth > width - 1) break;
180
+ const style = key.lit
181
+ ? { ...(key.black ? roles.selected : roles.hit), reverse: true }
182
+ : key.black
183
+ ? roles.muted
184
+ : roles.text;
185
+ buffer.text(x, y, cell, style);
186
+ x += cellWidth;
187
+ }
188
+ }