@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Play mode: the computer keyboard as a MIDI keyboard.
3
+ *
4
+ * The layout is the GarageBand/Logic "Musical Typing" convention that
5
+ * BandLab, FL Studio and Ableton follow:
6
+ *
7
+ * W E T Y U O P black keys (no key on R or I, matching the
8
+ * A S D F G H J K L ; ' missing black keys between E–F and B–C)
9
+ * C D E F G A B C D E F white keys from the base octave
10
+ *
11
+ * Z / X octave down / up C / V velocity down / up (steps of 16)
12
+ * Shift sustain while held Tab sustain latch Esc leave the mode
13
+ *
14
+ * Terminals report key-down only, so a held key is synthesized from the
15
+ * keyboard's auto-repeat: a press sounds for the gate (one grid step by
16
+ * default); repeats of the same key arriving at the repeat rate extend it,
17
+ * and it releases RELEASE_MS after the last repeat. The first repeat after
18
+ * the OS repeat delay cannot be told apart from a deliberate re-press until
19
+ * the next repeat confirms the burst, so that note is absorbed into the held
20
+ * one retroactively (`absorbed` on the extend action).
21
+ *
22
+ * Everything here is pure: callers pass the monotonic time of each key.
23
+ */
24
+ import {
25
+ SAMPLER_FIRST_SLOT,
26
+ isSamplerInstrument,
27
+ samplerVoiceSlots,
28
+ type Track,
29
+ } from "../../core/score.ts";
30
+ import { pitchName } from "../../tui/highway.ts";
31
+
32
+ /** Semitone offset from the base C for each note key. */
33
+ export const WHITE_KEYS: Readonly<Record<string, number>> = Object.freeze({
34
+ a: 0,
35
+ s: 2,
36
+ d: 4,
37
+ f: 5,
38
+ g: 7,
39
+ h: 9,
40
+ j: 11,
41
+ k: 12,
42
+ l: 14,
43
+ ";": 16,
44
+ "'": 17,
45
+ });
46
+
47
+ export const BLACK_KEYS: Readonly<Record<string, number>> = Object.freeze({
48
+ w: 1,
49
+ e: 3,
50
+ t: 6,
51
+ y: 8,
52
+ u: 10,
53
+ o: 13,
54
+ p: 15,
55
+ });
56
+
57
+ /** Every note key, white and black, to its offset. */
58
+ export const NOTE_KEYS: Readonly<Record<string, number>> = Object.freeze({
59
+ ...WHITE_KEYS,
60
+ ...BLACK_KEYS,
61
+ });
62
+
63
+ /** Shifted characters a terminal sends for each note key while Shift is held. */
64
+ const SHIFTED: Readonly<Record<string, string>> = Object.freeze({
65
+ ":": ";",
66
+ '"': "'",
67
+ });
68
+
69
+ /** Highest offset in the layout (the top F). */
70
+ export const LAYOUT_SPAN = 17;
71
+ export const MIN_PITCH = 0;
72
+ export const MAX_PITCH = 127;
73
+ /** Lowest and highest base C so every key stays inside MIDI 0..127. */
74
+ export const MIN_BASE = 0;
75
+ export const MAX_BASE = Math.floor((MAX_PITCH - LAYOUT_SPAN) / 12) * 12;
76
+ export const DEFAULT_BASE = 48;
77
+ export const DEFAULT_VELOCITY = 100;
78
+ export const VELOCITY_STEP = 16;
79
+ export const MIN_VELOCITY = 1;
80
+ export const MAX_VELOCITY = 127;
81
+ /** Release after the last auto-repeat of a held key. */
82
+ export const RELEASE_MS = 120;
83
+ /** Repeats closer than this confirm a held key (macOS default is ~90 ms). */
84
+ export const REPEAT_GAP_MS = 120;
85
+ /** The first repeat arrives after the OS repeat delay, at most this late. */
86
+ export const REPEAT_DELAY_MS = 700;
87
+
88
+ /**
89
+ * Base C that sits the keyboard in the instrument's comfortable range: bass
90
+ * an octave below the default, leads an octave above, kits on the GM drum
91
+ * map (A = kick 36, S = snare 38, T = closed hat 42).
92
+ */
93
+ export function defaultBaseFor(instrument: string | undefined): number {
94
+ const name = (instrument ?? "").toLowerCase();
95
+ if (/bass/.test(name)) return DEFAULT_BASE - 12;
96
+ if (/^(kit|drums?|drumkit)$/.test(name)) return 36;
97
+ if (/^(saw|square|triangle|pluck|lead)/.test(name)) return DEFAULT_BASE + 12;
98
+ return DEFAULT_BASE;
99
+ }
100
+
101
+ /**
102
+ * How the keyboard sits on a track: its default base C and, for one-shot
103
+ * samplers, the voice name each pitch slot plays (slots from 36 in
104
+ * voice-name order, as `samplerVoiceSlots` assigns them, so A plays the
105
+ * first voice, W the second, S the third and so on chromatically). Keyed
106
+ * samplers start at the C at or below the lowest root.
107
+ */
108
+ export type PlayLayout = Readonly<{
109
+ base: number;
110
+ /** Pitch → label (voice names on one-shot samplers). */
111
+ labels: ReadonlyMap<number, string>;
112
+ }>;
113
+
114
+ export function playLayoutFor(track: Track | undefined): PlayLayout {
115
+ const labels = new Map<number, string>();
116
+ if (track && isSamplerInstrument(track.instrument) && track.sampler) {
117
+ if (track.sampler.mode === "oneshot") {
118
+ for (const [voice, slot] of samplerVoiceSlots(track.sampler))
119
+ labels.set(slot, voice);
120
+ return { base: SAMPLER_FIRST_SLOT, labels };
121
+ }
122
+ const roots = Object.values(track.sampler.voices).map(
123
+ (ref) => ref.root ?? 60,
124
+ );
125
+ const lowest = roots.length > 0 ? Math.min(...roots) : 60;
126
+ return { base: clampBase(Math.floor(lowest / 12) * 12), labels };
127
+ }
128
+ // A plain instrument takes its range from the track's name (`bass`, `lead`).
129
+ const byInstrument = defaultBaseFor(track?.instrument);
130
+ const base =
131
+ byInstrument === DEFAULT_BASE && track
132
+ ? defaultBaseFor(track.name || track.id)
133
+ : byInstrument;
134
+ return { base, labels };
135
+ }
136
+
137
+ export function clampBase(base: number): number {
138
+ const octave = Math.round(base / 12) * 12;
139
+ return Math.max(MIN_BASE, Math.min(MAX_BASE, octave));
140
+ }
141
+
142
+ export function clampVelocity(velocity: number): number {
143
+ return Math.max(MIN_VELOCITY, Math.min(MAX_VELOCITY, Math.round(velocity)));
144
+ }
145
+
146
+ /** `C3–F4` for the keys' range at `base`. */
147
+ export function rangeLabel(base: number): string {
148
+ return `${pitchName(base)}–${pitchName(base + LAYOUT_SPAN)}`;
149
+ }
150
+
151
+ export type PlayCommand =
152
+ "exit" | "record" | "replace" | "click" | "transport" | "menu";
153
+
154
+ /** Keys that drive the mode itself rather than notes. */
155
+ const COMMAND_KEYS: Readonly<Record<string, PlayCommand>> = Object.freeze({
156
+ "\u001b": "exit",
157
+ r: "record",
158
+ R: "replace",
159
+ m: "click",
160
+ M: "click",
161
+ " ": "transport",
162
+ "\u000b": "menu",
163
+ });
164
+
165
+ export type PlayedNote = Readonly<{
166
+ /** Monotonically increasing per keyboard. */
167
+ id: number;
168
+ key: string;
169
+ pitch: number;
170
+ /** MIDI velocity 1..127. */
171
+ velocity: number;
172
+ atMs: number;
173
+ /** When the note stops; Infinity while sustained. */
174
+ releaseAtMs: number;
175
+ sustain: boolean;
176
+ }>;
177
+
178
+ export type PlayAction =
179
+ /** `released`: sustained notes that end now because Shift was lifted. */
180
+ | { type: "note"; note: PlayedNote; released: readonly number[] }
181
+ /**
182
+ * An auto-repeat of a sounding key: `id` now releases at `releaseAtMs`.
183
+ * `absorbed` is a tentative note (the first repeat) that belongs to `id`.
184
+ */
185
+ | {
186
+ type: "extend";
187
+ id: number;
188
+ key: string;
189
+ releaseAtMs: number;
190
+ absorbed?: number | undefined;
191
+ }
192
+ | { type: "octave"; base: number; clamped: boolean }
193
+ | { type: "velocity"; velocity: number; clamped: boolean }
194
+ /** Sustain turned off: every sustained note releases at `atMs`. */
195
+ | { type: "sustain"; on: boolean; released: readonly number[]; atMs: number }
196
+ | { type: "command"; command: PlayCommand }
197
+ /** Not a play-mode key; the caller handles it as usual. */
198
+ | { type: "unmapped" };
199
+
200
+ type KeyState = {
201
+ /** The note the key belongs to (the held one after absorption). */
202
+ id: number;
203
+ /** A tentative note from the first repeat, absorbed on confirmation. */
204
+ tentative: number | undefined;
205
+ lastMs: number;
206
+ /** Key events seen for this note: 1 = a single press. */
207
+ events: number;
208
+ confirmed: boolean;
209
+ };
210
+
211
+ export type PlayKeyboardOptions = Readonly<{
212
+ base?: number;
213
+ velocity?: number;
214
+ }>;
215
+
216
+ export class PlayKeyboard {
217
+ public base: number;
218
+ public velocity: number;
219
+ /** Tab latch. */
220
+ public latched = false;
221
+ private readonly keys = new Map<string, KeyState>();
222
+ private readonly releases = new Map<number, number>();
223
+ private readonly sustained = new Set<number>();
224
+ private lastKey: string | undefined;
225
+ private nextId = 1;
226
+ /** Shift was held on the last note key (BandLab's Sustain light). */
227
+ public shiftHeld = false;
228
+
229
+ public constructor(options: PlayKeyboardOptions = {}) {
230
+ this.base = clampBase(options.base ?? DEFAULT_BASE);
231
+ this.velocity = clampVelocity(options.velocity ?? DEFAULT_VELOCITY);
232
+ }
233
+
234
+ public get sustain(): boolean {
235
+ return this.latched || this.shiftHeld;
236
+ }
237
+
238
+ public get range(): string {
239
+ return rangeLabel(this.base);
240
+ }
241
+
242
+ /** Pitch a note key plays at the current octave, or undefined. */
243
+ public pitchFor(key: string): number | undefined {
244
+ const offset = NOTE_KEYS[SHIFTED[key] ?? key.toLowerCase()];
245
+ if (offset === undefined) return undefined;
246
+ return this.base + offset;
247
+ }
248
+
249
+ /** Release time of a note, or undefined once forgotten. */
250
+ public releaseOf(id: number): number | undefined {
251
+ return this.releases.get(id);
252
+ }
253
+
254
+ /** Note keys currently sounding at `nowMs` (for the strip). */
255
+ public litKeys(nowMs: number): Set<string> {
256
+ const lit = new Set<string>();
257
+ for (const [key, state] of this.keys) {
258
+ const release = this.releases.get(state.tentative ?? state.id);
259
+ if (release !== undefined && release > nowMs) lit.add(key);
260
+ }
261
+ return lit;
262
+ }
263
+
264
+ /**
265
+ * Handle one decoded key at monotonic `nowMs`. `gateMs` is how long a
266
+ * single press sounds (one grid step by default).
267
+ */
268
+ public press(value: string, nowMs: number, gateMs: number): PlayAction {
269
+ if (value === "\t") return this.toggleLatch(nowMs);
270
+ const command = COMMAND_KEYS[value];
271
+ if (command) return { type: "command", command };
272
+ const lower = value.length === 1 ? value.toLowerCase() : value;
273
+ if (lower === "z" || lower === "x") {
274
+ const wanted = this.base + (lower === "z" ? -12 : 12);
275
+ this.base = clampBase(wanted);
276
+ return { type: "octave", base: this.base, clamped: wanted !== this.base };
277
+ }
278
+ if (lower === "c" || lower === "v") {
279
+ // 100 → 116 → 127 and back down in 16s, never 0 (a silent note).
280
+ const wanted =
281
+ this.velocity + (lower === "c" ? -VELOCITY_STEP : VELOCITY_STEP);
282
+ this.velocity = clampVelocity(wanted);
283
+ return {
284
+ type: "velocity",
285
+ velocity: this.velocity,
286
+ clamped: wanted !== this.velocity,
287
+ };
288
+ }
289
+ const pitch = this.pitchFor(value);
290
+ if (pitch === undefined || value.length !== 1) return { type: "unmapped" };
291
+ const key = SHIFTED[value] ?? lower;
292
+ const shifted = value !== key;
293
+ const previousShift = this.shiftHeld;
294
+ this.shiftHeld = shifted;
295
+ const repeat = this.repeatOf(key, nowMs);
296
+ if (repeat) return repeat;
297
+ // A plain key after Shift-held notes lifts the (synthesized) Shift.
298
+ const released: number[] = [];
299
+ if (previousShift && !shifted && !this.latched)
300
+ released.push(...this.releaseSustained(nowMs));
301
+ const sustain = this.sustain;
302
+ const id = this.nextId++;
303
+ const releaseAtMs = sustain ? Infinity : nowMs + gateMs;
304
+ const previous = this.keys.get(key);
305
+ // A second event inside the repeat delay may be the first auto-repeat;
306
+ // keep it tentative so the next fast repeat can absorb it.
307
+ const tentativeOf =
308
+ previous &&
309
+ this.lastKey === key &&
310
+ previous.events === 1 &&
311
+ !previous.confirmed &&
312
+ nowMs - previous.lastMs <= REPEAT_DELAY_MS
313
+ ? previous.id
314
+ : undefined;
315
+ this.keys.set(key, {
316
+ id: tentativeOf ?? id,
317
+ tentative: tentativeOf === undefined ? undefined : id,
318
+ lastMs: nowMs,
319
+ events: tentativeOf === undefined ? 1 : 2,
320
+ confirmed: false,
321
+ });
322
+ this.lastKey = key;
323
+ this.releases.set(id, releaseAtMs);
324
+ if (sustain) this.sustained.add(id);
325
+ this.forget(nowMs);
326
+ const note: PlayedNote = {
327
+ id,
328
+ key,
329
+ pitch,
330
+ velocity: this.velocity,
331
+ atMs: nowMs,
332
+ releaseAtMs,
333
+ sustain,
334
+ };
335
+ return { type: "note", note, released };
336
+ }
337
+
338
+ private repeatOf(key: string, nowMs: number): PlayAction | undefined {
339
+ const state = this.keys.get(key);
340
+ if (!state || this.lastKey !== key) return undefined;
341
+ const gap = nowMs - state.lastMs;
342
+ if (gap > REPEAT_GAP_MS) return undefined;
343
+ const sounding = state.tentative ?? state.id;
344
+ const release = this.releases.get(sounding);
345
+ if (release === undefined) return undefined;
346
+ state.lastMs = nowMs;
347
+ state.events += 1;
348
+ state.confirmed = true;
349
+ const absorbed = state.tentative;
350
+ state.tentative = undefined;
351
+ const sustained =
352
+ this.sustained.has(state.id) ||
353
+ (absorbed !== undefined && this.sustained.has(absorbed));
354
+ const releaseAtMs = sustained
355
+ ? Infinity
356
+ : Math.max(release, nowMs + RELEASE_MS);
357
+ if (absorbed !== undefined) {
358
+ this.releases.delete(absorbed);
359
+ this.sustained.delete(absorbed);
360
+ if (sustained) this.sustained.add(state.id);
361
+ }
362
+ this.releases.set(state.id, releaseAtMs);
363
+ return {
364
+ type: "extend",
365
+ id: state.id,
366
+ key,
367
+ releaseAtMs,
368
+ ...(absorbed === undefined ? {} : { absorbed }),
369
+ };
370
+ }
371
+
372
+ private toggleLatch(nowMs: number): PlayAction {
373
+ this.latched = !this.latched;
374
+ const released = this.latched ? [] : this.releaseSustained(nowMs);
375
+ return { type: "sustain", on: this.latched, released, atMs: nowMs };
376
+ }
377
+
378
+ /** End every sustained note at `nowMs` (sustain pedal up). */
379
+ public releaseSustained(nowMs: number): number[] {
380
+ const released = [...this.sustained];
381
+ for (const id of released) this.releases.set(id, nowMs);
382
+ this.sustained.clear();
383
+ return released;
384
+ }
385
+
386
+ /** Drop bookkeeping for notes long gone, keeping the maps bounded. */
387
+ private forget(nowMs: number): void {
388
+ if (this.releases.size < 64) return;
389
+ for (const [id, release] of this.releases)
390
+ if (release + REPEAT_DELAY_MS < nowMs) this.releases.delete(id);
391
+ for (const [key, state] of this.keys)
392
+ if (!this.releases.has(state.id)) this.keys.delete(key);
393
+ }
394
+ }
395
+
396
+ /** One row of the on-screen keyboard: `a s d …` with lit keys marked. */
397
+ export type StripCell = Readonly<{
398
+ key: string;
399
+ label: string;
400
+ black: boolean;
401
+ lit: boolean;
402
+ }>;
403
+
404
+ /** The keys in physical order across the two rows, for the strip. */
405
+ export const STRIP_ORDER: readonly string[] = Object.freeze([
406
+ "a",
407
+ "w",
408
+ "s",
409
+ "e",
410
+ "d",
411
+ "f",
412
+ "t",
413
+ "g",
414
+ "y",
415
+ "h",
416
+ "u",
417
+ "j",
418
+ "k",
419
+ "o",
420
+ "l",
421
+ "p",
422
+ ";",
423
+ "'",
424
+ ]);
425
+
426
+ export function stripCells(
427
+ keyboard: PlayKeyboard,
428
+ lit: ReadonlySet<string>,
429
+ labels: ReadonlyMap<number, string> = new Map(),
430
+ ): StripCell[] {
431
+ return STRIP_ORDER.map((key) => {
432
+ const pitch = keyboard.pitchFor(key)!;
433
+ const name =
434
+ labels.size > 0 ? (labels.get(pitch) ?? "·") : pitchName(pitch);
435
+ return {
436
+ key,
437
+ label: name,
438
+ black: BLACK_KEYS[key] !== undefined,
439
+ lit: lit.has(key),
440
+ };
441
+ });
442
+ }