@hraness/dawg 0.3.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 (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,472 @@
1
+ /**
2
+ * The Euclidean rhythm editor (`/euclid`, or Rhythm in `/menu`): one row per
3
+ * voice of the focused track, Torso T-1 style. Each row shows the voice's
4
+ * step ring unrolled as a grid and the selected parameter; arrows nudge it.
5
+ *
6
+ * Like the edit menu (`src/tui/menu.ts`) the editor is pure: rows are
7
+ * rebuilt from the score on every key and a change is returned as the
8
+ * prompt command it stands for (`euclid hat rotate 3`), which the caller
9
+ * runs through the normal prompt path, so every change is one revision and
10
+ * one undo step and the row's detail teaches the command.
11
+ *
12
+ * Keys: ↑↓ voice · ←→ (h l + -) nudge · tab / shift-tab (] [) parameter ·
13
+ * digits type a value · enter add/edit · space audition · x off · f freeze ·
14
+ * esc back.
15
+ */
16
+ import { HINTS } from "../../tui/grammar.ts";
17
+ import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
18
+ import {
19
+ DIVISIONS,
20
+ RHYTHM_DEFAULTS,
21
+ RHYTHM_LIMITS,
22
+ rowPattern,
23
+ rowSteps,
24
+ rowSummary,
25
+ type RhythmRow,
26
+ } from "../../core/euclid.ts";
27
+ import {
28
+ isSamplerInstrument,
29
+ type Track,
30
+ type TrackScore,
31
+ } from "../../core/score.ts";
32
+ import type { PickerItem } from "../../tui/app.ts";
33
+
34
+ export type EuclidContext = Readonly<{ score: TrackScore; trackId: string }>;
35
+
36
+ export type EuclidResult =
37
+ | { type: "handled" }
38
+ | { type: "close" }
39
+ /** Run `command`; `audition` names the voice to play once it lands. */
40
+ | { type: "run"; command: string; audition?: string }
41
+ | { type: "audition"; voice: string }
42
+ | { type: "pass" };
43
+
44
+ export type EuclidView = Readonly<{
45
+ title: string;
46
+ items: PickerItem[];
47
+ index: number;
48
+ hint: string;
49
+ }>;
50
+
51
+ type Param = Readonly<{
52
+ /** Field name in `RhythmRow` and in the `euclid` grammar. */
53
+ field: keyof RhythmRow & string;
54
+ label: string;
55
+ value: (row: RhythmRow) => number | string;
56
+ /** Next value one step in `direction`, or undefined at a limit. */
57
+ step: (row: RhythmRow, direction: 1 | -1) => number | string | undefined;
58
+ }>;
59
+
60
+ const round = (value: number) => Math.round(value * 1000) / 1000;
61
+
62
+ function linear(
63
+ field: keyof RhythmRow & string,
64
+ fallback: number,
65
+ size: number,
66
+ min: number,
67
+ max: (row: RhythmRow) => number,
68
+ ) {
69
+ return (row: RhythmRow, direction: 1 | -1) => {
70
+ const current = (row[field] as number | undefined) ?? fallback;
71
+ const next = round(
72
+ Math.min(max(row), Math.max(min, current + size * direction)),
73
+ );
74
+ return next === current ? undefined : next;
75
+ };
76
+ }
77
+
78
+ function cycle(
79
+ field: "division" | "time",
80
+ fallback: (row: RhythmRow) => string,
81
+ ) {
82
+ return (row: RhythmRow, direction: 1 | -1) => {
83
+ const current = (row[field] as string | undefined) ?? fallback(row);
84
+ const at = DIVISIONS.indexOf(current);
85
+ // Divisions run fastest to slowest; → makes steps longer.
86
+ const next = DIVISIONS[at < 0 ? 0 : at + direction];
87
+ return next === undefined || next === current ? undefined : next;
88
+ };
89
+ }
90
+
91
+ const division = (row: RhythmRow) => row.division ?? RHYTHM_DEFAULTS.division;
92
+
93
+ /** The editable parameters, in T-1 order (Shape, then Groove). */
94
+ export const EUCLID_PARAMS: readonly Param[] = Object.freeze([
95
+ {
96
+ field: "pulses",
97
+ label: "pulses",
98
+ value: (row) => row.pulses ?? RHYTHM_DEFAULTS.pulses,
99
+ step: linear("pulses", RHYTHM_DEFAULTS.pulses, 1, 0, rowSteps),
100
+ },
101
+ {
102
+ field: "steps",
103
+ label: "steps",
104
+ value: rowSteps,
105
+ step: (row, direction) => {
106
+ const next = Math.min(
107
+ RHYTHM_LIMITS.maxSteps,
108
+ Math.max(1, rowSteps(row) + direction),
109
+ );
110
+ return next === rowSteps(row) ? undefined : next;
111
+ },
112
+ },
113
+ {
114
+ field: "rotate",
115
+ label: "rotate",
116
+ value: (row) => row.rotate ?? 0,
117
+ step: (row, direction) => {
118
+ // Rotation wraps around the ring, so it stays in 0..steps-1.
119
+ const steps = rowSteps(row);
120
+ if (steps <= 1) return undefined;
121
+ const current = (((row.rotate ?? 0) % steps) + steps) % steps;
122
+ return (current + direction + steps) % steps;
123
+ },
124
+ },
125
+ {
126
+ field: "division",
127
+ label: "division",
128
+ value: division,
129
+ step: cycle("division", division),
130
+ },
131
+ {
132
+ field: "repeats",
133
+ label: "repeats",
134
+ value: (row) => row.repeats ?? 0,
135
+ step: linear("repeats", 0, 1, 0, () => RHYTHM_LIMITS.maxRepeats),
136
+ },
137
+ {
138
+ field: "time",
139
+ label: "time",
140
+ value: (row) => row.time ?? division(row),
141
+ step: cycle("time", division),
142
+ },
143
+ {
144
+ field: "pace",
145
+ label: "pace",
146
+ value: (row) => row.pace ?? 0,
147
+ step: linear("pace", 0, 0.1, -1, () => 1),
148
+ },
149
+ {
150
+ field: "ramp",
151
+ label: "ramp",
152
+ value: (row) => row.ramp ?? 0,
153
+ step: linear("ramp", 0, 0.1, -1, () => 1),
154
+ },
155
+ {
156
+ field: "velocity",
157
+ label: "velocity",
158
+ value: (row) => row.velocity ?? RHYTHM_DEFAULTS.velocity,
159
+ step: linear("velocity", RHYTHM_DEFAULTS.velocity, 0.05, 0, () => 1),
160
+ },
161
+ {
162
+ field: "accent",
163
+ label: "accent",
164
+ value: (row) => row.accent ?? 0,
165
+ step: linear("accent", 0, 0.1, 0, () => 1),
166
+ },
167
+ {
168
+ field: "accents",
169
+ label: "accents",
170
+ value: (row) => row.accents ?? 1,
171
+ step: linear(
172
+ "accents",
173
+ 1,
174
+ 1,
175
+ 0,
176
+ (row) => row.pulses ?? RHYTHM_DEFAULTS.pulses,
177
+ ),
178
+ },
179
+ {
180
+ field: "gate",
181
+ label: "gate",
182
+ value: (row) => row.gate ?? 1,
183
+ step: linear(
184
+ "gate",
185
+ 1,
186
+ 0.25,
187
+ RHYTHM_LIMITS.minGate,
188
+ () => RHYTHM_LIMITS.maxGate,
189
+ ),
190
+ },
191
+ {
192
+ field: "probability",
193
+ label: "probability",
194
+ value: (row) => row.probability ?? 1,
195
+ step: linear("probability", 1, 0.05, 0, () => 1),
196
+ },
197
+ {
198
+ field: "seed",
199
+ label: "seed",
200
+ value: (row) => row.seed ?? 0,
201
+ step: linear("seed", 0, 1, 0, () => RHYTHM_LIMITS.maxSeed),
202
+ },
203
+ {
204
+ field: "swing",
205
+ label: "swing",
206
+ value: (row) => row.swing ?? 0,
207
+ step: linear(
208
+ "swing",
209
+ 0,
210
+ 0.05,
211
+ -RHYTHM_LIMITS.maxSwing,
212
+ () => RHYTHM_LIMITS.maxSwing,
213
+ ),
214
+ },
215
+ {
216
+ field: "nudge",
217
+ label: "nudge",
218
+ value: (row) => row.nudge ?? 0,
219
+ step: linear(
220
+ "nudge",
221
+ 0,
222
+ 0.05,
223
+ -RHYTHM_LIMITS.maxNudge,
224
+ () => RHYTHM_LIMITS.maxNudge,
225
+ ),
226
+ },
227
+ ]);
228
+
229
+ /** Parameters a grid row has (its steps are explicit). */
230
+ const GRID_SKIPS = new Set(["pulses", "steps", "rotate"]);
231
+
232
+ type Lane = Readonly<{ voice: string; label: string; row?: RhythmRow }>;
233
+
234
+ /** One lane per drum voice (or sampler voice), plus pitch rows on melodic tracks. */
235
+ export function euclidLanes(track: Track): Lane[] {
236
+ const rows = new Map((track.rhythm ?? []).map((row) => [row.voice, row]));
237
+ const lanes: Lane[] = [];
238
+ const seen = new Set<string>();
239
+ const add = (voice: string, label: string) => {
240
+ seen.add(voice);
241
+ const row = rows.get(voice);
242
+ lanes.push(row ? { voice, label, row } : { voice, label });
243
+ };
244
+ if (
245
+ isSamplerInstrument(track.instrument) &&
246
+ track.sampler?.mode === "oneshot"
247
+ )
248
+ for (const voice of Object.keys(track.sampler.voices).sort())
249
+ add(voice, voice);
250
+ else if (isDrumInstrument(track.instrument))
251
+ for (const info of DRUM_VOICES) add(info.voice, info.label);
252
+ for (const row of track.rhythm ?? [])
253
+ if (!seen.has(row.voice)) add(row.voice, row.voice);
254
+ return lanes;
255
+ }
256
+
257
+ /** `x...x...` with accents as `X`, rests as `·`. */
258
+ export function ringText(row: RhythmRow | undefined): string {
259
+ if (!row) return "·".repeat(RHYTHM_DEFAULTS.steps);
260
+ const pattern = rowPattern(row);
261
+ const grid = row.grid;
262
+ return pattern
263
+ .map((on, index) => (on ? (grid?.[index] === "X" ? "X" : "x") : "·"))
264
+ .join("");
265
+ }
266
+
267
+ const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
268
+ const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
269
+ const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
270
+ const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
271
+ const KEY_NEXT_PARAM = new Set(["\t", "]"]);
272
+ const KEY_PREV_PARAM = new Set(["\u001b[Z", "["]);
273
+ const KEY_ENTER = new Set(["\r", "\n"]);
274
+ const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
275
+ const KEY_OFF = new Set(["x", "\u001b[3~"]);
276
+
277
+ export class EuclidEditor {
278
+ private visible = false;
279
+ private lane = 0;
280
+ private param = 0;
281
+ private entry: string | undefined;
282
+ /** Where Esc returns to (`menu` when opened from `/menu`). */
283
+ private origin: string | undefined;
284
+
285
+ get open(): boolean {
286
+ return this.visible;
287
+ }
288
+
289
+ /** Where the editor was opened from, for Esc to return there. */
290
+ /** True while a value is being typed. */
291
+ get typing(): boolean {
292
+ return this.entry !== undefined;
293
+ }
294
+
295
+ get returnTo(): string | undefined {
296
+ return this.origin;
297
+ }
298
+
299
+ show(context: EuclidContext, voice?: string, origin?: string): void {
300
+ this.visible = true;
301
+ this.entry = undefined;
302
+ this.origin = origin;
303
+ const lanes = this.lanes(context);
304
+ const at = voice ? lanes.findIndex((lane) => lane.voice === voice) : -1;
305
+ if (at >= 0) this.lane = at;
306
+ else {
307
+ // Land on the first voice that already has a row.
308
+ const first = lanes.findIndex((lane) => lane.row);
309
+ this.lane = Math.max(0, Math.min(this.lane, lanes.length - 1), first);
310
+ if (first >= 0) this.lane = first;
311
+ }
312
+ }
313
+
314
+ close(): void {
315
+ this.visible = false;
316
+ this.entry = undefined;
317
+ }
318
+
319
+ private lanes(context: EuclidContext): Lane[] {
320
+ const track = context.score.tracks.find((t) => t.id === context.trackId);
321
+ return track ? euclidLanes(track) : [];
322
+ }
323
+
324
+ /** Voice under the cursor. */
325
+ selectedVoice(context: EuclidContext): string | undefined {
326
+ const lanes = this.lanes(context);
327
+ return lanes[Math.min(this.lane, lanes.length - 1)]?.voice;
328
+ }
329
+
330
+ /** Parameter under the cursor. */
331
+ get selectedParam(): string {
332
+ return EUCLID_PARAMS[this.param]!.field;
333
+ }
334
+
335
+ key(value: string, context: EuclidContext): EuclidResult {
336
+ if (!this.visible) return { type: "pass" };
337
+ if (value === "\u0003" || value === "\u000c") return { type: "pass" };
338
+ const lanes = this.lanes(context);
339
+ if (lanes.length > 0) this.lane = Math.min(this.lane, lanes.length - 1);
340
+ const lane = lanes[this.lane];
341
+ const param = EUCLID_PARAMS[this.param]!;
342
+ if (this.entry !== undefined) {
343
+ if (value === "\u001b") {
344
+ this.entry = undefined;
345
+ return { type: "handled" };
346
+ }
347
+ if (KEY_BACKSPACE.has(value)) {
348
+ this.entry = this.entry.slice(0, -1);
349
+ return { type: "handled" };
350
+ }
351
+ if (KEY_ENTER.has(value)) {
352
+ const text = this.entry.trim();
353
+ this.entry = undefined;
354
+ if (!lane || !/^-?[0-9./tT]+$/.test(text)) return { type: "handled" };
355
+ return {
356
+ type: "run",
357
+ command: `euclid ${lane.voice} ${param.field} ${text}`,
358
+ audition: lane.voice,
359
+ };
360
+ }
361
+ if (/^[0-9./tT-]$/.test(value))
362
+ this.entry = (this.entry + value).slice(0, 12);
363
+ return { type: "handled" };
364
+ }
365
+ if (value === "\u001b") return { type: "close" };
366
+ if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
367
+ if (lanes.length)
368
+ this.lane =
369
+ (this.lane + (KEY_UP.has(value) ? -1 : 1) + lanes.length) %
370
+ lanes.length;
371
+ return { type: "handled" };
372
+ }
373
+ if (KEY_NEXT_PARAM.has(value) || KEY_PREV_PARAM.has(value)) {
374
+ const direction = KEY_NEXT_PARAM.has(value) ? 1 : -1;
375
+ this.param =
376
+ (this.param + direction + EUCLID_PARAMS.length) % EUCLID_PARAMS.length;
377
+ return { type: "handled" };
378
+ }
379
+ if (!lane) return { type: "handled" };
380
+ if (value === " ") return { type: "audition", voice: lane.voice };
381
+ if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
382
+ const direction = KEY_RIGHT.has(value) ? 1 : -1;
383
+ const row: RhythmRow = lane.row ?? { voice: lane.voice };
384
+ if (row.grid !== undefined && GRID_SKIPS.has(param.field)) {
385
+ // A nudge on a grid row's shape turns it back into E(pulses, steps).
386
+ const pulses = [...row.grid].filter((c) => c !== ".").length;
387
+ return {
388
+ type: "run",
389
+ command: `euclid ${lane.voice} ${pulses} ${row.grid.length}`,
390
+ audition: lane.voice,
391
+ };
392
+ }
393
+ const next = param.step(row, direction);
394
+ if (next === undefined) return { type: "handled" };
395
+ return {
396
+ type: "run",
397
+ command: `euclid ${lane.voice} ${param.field} ${next}`,
398
+ audition: lane.voice,
399
+ };
400
+ }
401
+ if (/^[0-9]$/.test(value)) {
402
+ this.entry = value;
403
+ return { type: "handled" };
404
+ }
405
+ if (KEY_ENTER.has(value)) {
406
+ if (!lane.row)
407
+ return {
408
+ type: "run",
409
+ command: `euclid ${lane.voice} ${RHYTHM_DEFAULTS.pulses} ${RHYTHM_DEFAULTS.steps}`,
410
+ audition: lane.voice,
411
+ };
412
+ this.entry = "";
413
+ return { type: "handled" };
414
+ }
415
+ if (KEY_OFF.has(value))
416
+ return lane.row
417
+ ? { type: "run", command: `euclid ${lane.voice} off` }
418
+ : { type: "handled" };
419
+ if (value === "f")
420
+ return lane.row
421
+ ? { type: "run", command: `euclid ${lane.voice} freeze` }
422
+ : { type: "handled" };
423
+ // Swallow other printable keys so nothing leaks into the prompt.
424
+ return { type: "handled" };
425
+ }
426
+
427
+ view(context: EuclidContext): EuclidView {
428
+ const lanes = this.lanes(context);
429
+ const index = Math.max(0, Math.min(this.lane, lanes.length - 1));
430
+ const param = EUCLID_PARAMS[this.param]!;
431
+ const width = Math.max(5, ...lanes.map((lane) => lane.label.length));
432
+ const items: PickerItem[] = lanes.map((lane, at) => {
433
+ const row = lane.row;
434
+ const shown = row
435
+ ? row.grid !== undefined && GRID_SKIPS.has(param.field)
436
+ ? "grid"
437
+ : formatValue(param.value(row))
438
+ : "—";
439
+ return {
440
+ label: `${lane.label.padEnd(width)} ${ringText(row)}`,
441
+ detail: row
442
+ ? `${rowSummary(row)} · ${param.label} ${shown}`
443
+ : "enter adds E(4,16)",
444
+ value: String(at),
445
+ current: row !== undefined,
446
+ };
447
+ });
448
+ const voice = lanes[index]?.voice ?? "";
449
+ const row = lanes[index]?.row;
450
+ const value = row ? formatValue(param.value(row)) : "—";
451
+ let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${param.label} ${value}`;
452
+ if (this.entry !== undefined) title += ` · ${param.label}: ${this.entry}▏`;
453
+ const hint = this.entry !== undefined ? HINTS.typing : HINTS.euclid;
454
+ return {
455
+ title,
456
+ items: items.length
457
+ ? items
458
+ : [
459
+ {
460
+ label: "no voices · instrument kit makes a drum track",
461
+ value: "none",
462
+ },
463
+ ],
464
+ index,
465
+ hint,
466
+ };
467
+ }
468
+ }
469
+
470
+ function formatValue(value: number | string): string {
471
+ return typeof value === "number" ? String(round(value)) : value;
472
+ }