@hraness/dawg 0.3.0 → 0.4.1

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 +138 -0
  2. package/DAWG.md +608 -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 +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -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 +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -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 +411 -295
  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 +1222 -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 +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -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 +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Drum patterns and synthesized kits as score edits.
3
+ *
4
+ * /pattern browse the library (picker with preview)
5
+ * /pattern <name> put the pattern on the focused drum track
6
+ * /pattern list the library as text
7
+ * /kit <synth kit> syn808, syn909, acoustic, lofi, electro, trap
8
+ * /kit default the built-in voices again
9
+ *
10
+ * Sample kits (`/kit 909`, `/kit RolandTR808`) stay in `commands/pack.ts`;
11
+ * `/kit` with no name opens one picker listing both kinds.
12
+ *
13
+ * Every edit is one next score, so one revision and one undo step.
14
+ */
15
+ import {
16
+ drumVoicePitch,
17
+ isDrumInstrument,
18
+ parseDrumVoice,
19
+ } from "../../core/drums.ts";
20
+ import { normalizeRhythmRow, type RhythmRow } from "../../core/euclid.ts";
21
+ import { synthKit, SYNTH_KITS } from "../../core/kits.ts";
22
+ import { rhythmVoicePitch, withTrackRhythm } from "../../core/rhythm.ts";
23
+ import {
24
+ isSamplerInstrument,
25
+ samplerVoiceSlots,
26
+ ScoreValidationError,
27
+ TrackScore,
28
+ type Note,
29
+ type Track,
30
+ } from "../../core/score.ts";
31
+ import {
32
+ DRUM_PATTERNS,
33
+ findPattern,
34
+ type DrumPattern,
35
+ } from "../../core/sdk/v1.ts";
36
+
37
+ export { DRUM_PATTERNS, findPattern, type DrumPattern };
38
+
39
+ export type PatternCommand =
40
+ | Readonly<{ kind: "browse" }>
41
+ | Readonly<{ kind: "list" }>
42
+ | Readonly<{ kind: "apply"; name: string; tempo: TempoMode }>;
43
+
44
+ /**
45
+ * `auto` moves the tempo only when it is outside the pattern's range;
46
+ * `keep` never moves it; `set` always uses the pattern's tempo.
47
+ */
48
+ export type TempoMode = "auto" | "keep" | "set";
49
+
50
+ /** `/pattern`, `/pattern list`, `/pattern <name> [keep-tempo|tempo]`. */
51
+ export function parsePatternCommand(
52
+ command: string,
53
+ ): PatternCommand | undefined {
54
+ const match = command
55
+ .trim()
56
+ .match(
57
+ /^\/patterns?(?:\s+([a-z0-9][\w-]{0,40}))?(?:\s+(keep-tempo|keep|tempo))?\s*$/i,
58
+ );
59
+ if (!match) return undefined;
60
+ const name = match[1]?.toLowerCase();
61
+ if (!name) return { kind: "browse" };
62
+ if (name === "list" || name === "ls") return { kind: "list" };
63
+ const flag = match[2]?.toLowerCase();
64
+ return {
65
+ kind: "apply",
66
+ name,
67
+ tempo: flag === "tempo" ? "set" : flag ? "keep" : "auto",
68
+ };
69
+ }
70
+
71
+ /** One line per pattern for `/pattern list` and the agent. */
72
+ export function patternLine(entry: DrumPattern): string {
73
+ const voices = entry.rows.map((row) => row.voice).join(" ");
74
+ return `${entry.name} · ${entry.tempo.min}–${entry.tempo.max} BPM · ${entry.tags.join(", ")} · ${voices}`;
75
+ }
76
+
77
+ export type DrumEdit = Readonly<{
78
+ ok: boolean;
79
+ message: string;
80
+ next?: TrackScore;
81
+ kind?: string;
82
+ payload?: Record<string, unknown>;
83
+ }>;
84
+
85
+ function rowsOf(entry: DrumPattern): RhythmRow[] {
86
+ return entry.rows.map((spec, index) => {
87
+ const { kind: _kind, ...fields } = spec;
88
+ return normalizeRhythmRow(fields, `${entry.name} rows[${index}]`);
89
+ });
90
+ }
91
+
92
+ /**
93
+ * The focused track as a drum track: a kit track as is, a missing or empty
94
+ * melodic track as a new kit track. Undefined for a melodic track with
95
+ * notes or a keyed sampler, which a pattern must not overwrite.
96
+ */
97
+ function drumTrack(
98
+ score: TrackScore,
99
+ trackId: string,
100
+ ): { score: TrackScore; track: Track } | undefined {
101
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
102
+ if (!track) {
103
+ const next = score.withTracks([
104
+ ...score.tracks,
105
+ { id: trackId, name: trackId, instrument: "kit" },
106
+ ]);
107
+ return { score: next, track: next.tracks.at(-1)! };
108
+ }
109
+ if (isDrumInstrument(track.instrument)) return { score, track };
110
+ if (isSamplerInstrument(track.instrument))
111
+ return track.sampler?.mode === "oneshot" ? { score, track } : undefined;
112
+ if (score.notes.some((note) => note.trackId === trackId)) return undefined;
113
+ const next = score.withTracks(
114
+ score.tracks.map((candidate) =>
115
+ candidate.id === trackId
116
+ ? { ...candidate, instrument: "kit", rhythm: null }
117
+ : candidate,
118
+ ),
119
+ );
120
+ return { score: next, track: next.tracks.find((t) => t.id === trackId)! };
121
+ }
122
+
123
+ /**
124
+ * The score with `name` on `trackId`: the track's notes and rows are
125
+ * replaced by the pattern's rows (voices the track lacks are skipped), and
126
+ * the tempo follows `tempo`.
127
+ */
128
+ export function applyDrumPattern(
129
+ score: TrackScore,
130
+ trackId: string,
131
+ name: string,
132
+ tempo: TempoMode = "auto",
133
+ ): DrumEdit {
134
+ const entry = findPattern(name);
135
+ if (!entry)
136
+ return {
137
+ ok: false,
138
+ message: `pattern · no ${name.slice(0, 40)} · /pattern list`,
139
+ };
140
+ const target = drumTrack(score, trackId);
141
+ if (!target)
142
+ return {
143
+ ok: false,
144
+ message: `pattern · ${trackId} is a melodic track · focus a drum track (/track drums) and retry`,
145
+ };
146
+ const rows = rowsOf(entry);
147
+ const playable = rows.filter(
148
+ (row) => rhythmVoicePitch(target.track, row.voice) !== undefined,
149
+ );
150
+ const skipped = rows
151
+ .filter((row) => !playable.includes(row))
152
+ .map((row) => row.voice);
153
+ if (playable.length === 0)
154
+ return {
155
+ ok: false,
156
+ message: `pattern · ${trackId} has none of ${rows.map((row) => row.voice).join("/")}`,
157
+ };
158
+ const moveTempo =
159
+ tempo === "set" ||
160
+ (tempo === "auto" &&
161
+ (score.tempoBpm < entry.tempo.min || score.tempoBpm > entry.tempo.max));
162
+ const cleared = new TrackScore({
163
+ ...target.score.toJSON(),
164
+ ...(moveTempo ? { tempoBpm: entry.tempo.bpm } : {}),
165
+ tracks: target.score.tracks.map((candidate) =>
166
+ candidate.id === trackId ? { ...candidate, rhythm: null } : candidate,
167
+ ),
168
+ notes: target.score.notes.filter((note) => note.trackId !== trackId),
169
+ });
170
+ let next: TrackScore;
171
+ try {
172
+ next = withTrackRhythm(cleared, trackId, playable);
173
+ } catch (error) {
174
+ if (error instanceof ScoreValidationError)
175
+ return { ok: false, message: `pattern · ${error.message}` };
176
+ throw error;
177
+ }
178
+ const parts = [`pattern ${entry.name} on ${trackId}`];
179
+ if (moveTempo) parts.push(`${entry.tempo.bpm} BPM`);
180
+ if (skipped.length) parts.push(`no ${skipped.join("/")}`);
181
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
182
+ if (isDrumInstrument(track?.instrument) && !track?.kit)
183
+ parts.push(`try /kit ${entry.kit}`);
184
+ return {
185
+ ok: true,
186
+ message: parts.join(" · "),
187
+ next,
188
+ kind: "score.pattern",
189
+ payload: {
190
+ trackId,
191
+ pattern: entry.name,
192
+ ...(moveTempo ? { tempoBpm: entry.tempo.bpm } : {}),
193
+ },
194
+ };
195
+ }
196
+
197
+ /** True for names `/kit` should treat as a synthesized kit. */
198
+ export function isSynthKitName(name: string): boolean {
199
+ return (
200
+ name.trim().toLowerCase() === "default" || synthKit(name) !== undefined
201
+ );
202
+ }
203
+
204
+ /**
205
+ * The score with synth kit `name` (`default` for the built-in voices) on
206
+ * `trackId`. A oneshot sampler kit (from `/kit 909`) turns back into a
207
+ * synth kit track; its hits and rows move to the drum voices of the same
208
+ * name, and voices without one are dropped.
209
+ */
210
+ export function applySynthKit(
211
+ score: TrackScore,
212
+ trackId: string,
213
+ name: string,
214
+ ): DrumEdit {
215
+ const isDefault = name.trim().toLowerCase() === "default";
216
+ const kit = isDefault ? undefined : synthKit(name);
217
+ if (!isDefault && !kit)
218
+ return {
219
+ ok: false,
220
+ message: `kit · no synth kit ${name.slice(0, 40)} (default ${SYNTH_KITS.map((entry) => entry.name).join(" ")})`,
221
+ };
222
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
223
+ const label = kit?.name ?? "default";
224
+ const done = (next: TrackScore, extra = ""): DrumEdit => ({
225
+ ok: true,
226
+ message: `kit ${label} on ${trackId}${extra}`,
227
+ next,
228
+ kind: "score.kit",
229
+ payload: { trackId, kit: label },
230
+ });
231
+ if (!track)
232
+ return done(
233
+ score.withTracks([
234
+ ...score.tracks,
235
+ {
236
+ id: trackId,
237
+ name: trackId,
238
+ instrument: "kit",
239
+ ...(kit ? { kit: kit.name } : {}),
240
+ },
241
+ ]),
242
+ );
243
+ if (isDrumInstrument(track.instrument)) {
244
+ if ((track.kit ?? undefined) === kit?.name)
245
+ return { ok: true, message: `kit ${label} on ${trackId} already` };
246
+ return done(
247
+ score.withTracks(
248
+ score.tracks.map((candidate) =>
249
+ candidate.id === trackId
250
+ ? { ...candidate, kit: kit ? kit.name : null }
251
+ : candidate,
252
+ ),
253
+ ),
254
+ );
255
+ }
256
+ const oneshot =
257
+ isSamplerInstrument(track.instrument) && track.sampler?.mode === "oneshot";
258
+ const hasNotes = score.notes.some((note) => note.trackId === trackId);
259
+ if (!oneshot && hasNotes)
260
+ return {
261
+ ok: false,
262
+ message: `kit · ${trackId} is a melodic track · focus a drum track (/track drums) and retry`,
263
+ };
264
+ const pitchOf = new Map<number, number>();
265
+ if (oneshot && track.sampler)
266
+ for (const [voice, slot] of samplerVoiceSlots(track.sampler)) {
267
+ const drum = parseDrumVoice(voice);
268
+ if (drum) pitchOf.set(slot, drumVoicePitch(drum));
269
+ }
270
+ const notes: Note[] = [];
271
+ let dropped = 0;
272
+ for (const note of score.notes) {
273
+ if (note.trackId !== trackId) {
274
+ notes.push(note);
275
+ continue;
276
+ }
277
+ const pitch = pitchOf.get(note.pitch);
278
+ if (pitch === undefined) dropped += 1;
279
+ else notes.push({ ...note, pitch });
280
+ }
281
+ const rows = (track.rhythm ?? []).filter(
282
+ (row) => parseDrumVoice(row.voice) !== undefined,
283
+ );
284
+ const next = new TrackScore({
285
+ ...score.toJSON(),
286
+ tracks: score.tracks.map((candidate) => {
287
+ if (candidate.id !== trackId) return candidate;
288
+ const { sampler: _sampler, ...rest } = candidate;
289
+ return {
290
+ ...rest,
291
+ instrument: "kit",
292
+ rhythm: rows.length > 0 ? rows : null,
293
+ ...(kit ? { kit: kit.name } : {}),
294
+ };
295
+ }),
296
+ notes,
297
+ });
298
+ return done(next, dropped ? ` · ${dropped} hits had no synth voice` : "");
299
+ }
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * track name <text> rename the focused track
6
6
  * meter <beats per bar> time signature numerator (1..16)
7
+ * key <name>|none song key (`C major`, `F# dorian`)
7
8
  * automate <lane> points <beat:value>... merge points into a lane
8
9
  * automate <lane> remove <beat> drop the point at a beat
9
10
  *
@@ -12,7 +13,8 @@
12
13
  * score and the session event, leaving persistence to the caller.
13
14
  */
14
15
  import {
15
- AUTOMATION_LANES,
16
+ automationPoints,
17
+ automationRange,
16
18
  SCORE_LIMITS,
17
19
  applyScoreOperation,
18
20
  type AutomationParameter,
@@ -20,11 +22,13 @@ import {
20
22
  type ScoreOperation,
21
23
  type TrackScore,
22
24
  } from "../../core/score.ts";
25
+ import { keyName, parseKey } from "../../core/chords.ts";
23
26
  import { parseEffectLane } from "./music.ts";
24
27
 
25
28
  export type EditCommand =
26
29
  | { type: "track-name"; name: string }
27
30
  | { type: "meter"; beatsPerBar: number }
31
+ | { type: "key"; key: string | null }
28
32
  | {
29
33
  type: "automation-points";
30
34
  parameter: AutomationParameter;
@@ -58,11 +62,18 @@ export function parseEditCommand(prompt: string): EditCommand | undefined {
58
62
  ? { type: "meter", beatsPerBar }
59
63
  : undefined;
60
64
  }
65
+ const key = text.match(/^\/?key (.+)$/i);
66
+ if (key) {
67
+ const value = key[1]!.trim();
68
+ if (/^(none|off|clear)$/i.test(value)) return { type: "key", key: null };
69
+ const parsed = parseKey(value);
70
+ return parsed ? { type: "key", key: keyName(parsed) } : undefined;
71
+ }
61
72
  const points = text.match(/^automate ([a-z-]+) points((?: \S+)+)$/i);
62
73
  if (points) {
63
74
  const parameter = parseLane(points[1]!);
64
75
  if (!parameter) return undefined;
65
- const { min, max } = AUTOMATION_LANES[parameter];
76
+ const { min, max } = automationRange(parameter);
66
77
  const parsed: { beat: number; value: number }[] = [];
67
78
  for (const pair of points[2]!.trim().split(" ")) {
68
79
  const [beatText, valueText, extra] = pair.split(":");
@@ -116,6 +127,15 @@ export function applyEditCommand(
116
127
  payload: { beatsPerBar: command.beatsPerBar },
117
128
  };
118
129
  }
130
+ if (command.type === "key") {
131
+ return {
132
+ ok: true,
133
+ message: `key · ${command.key ?? "none"}`,
134
+ next: applyScoreOperation(score, { type: "setKey", key: command.key }),
135
+ kind: "score.key",
136
+ payload: { key: command.key },
137
+ };
138
+ }
119
139
  if (!track) return { ok: false, message: `no track ${trackId}` };
120
140
  if (command.type === "track-name") {
121
141
  const patch = { name: command.name };
@@ -131,8 +151,10 @@ export function applyEditCommand(
131
151
  payload: { trackId, patch },
132
152
  };
133
153
  }
134
- const field = AUTOMATION_LANES[command.parameter].field;
135
- const current: readonly AutomationPoint[] = track[field] ?? [];
154
+ const current: readonly AutomationPoint[] = automationPoints(
155
+ track,
156
+ command.parameter,
157
+ );
136
158
  let points: AutomationPoint[];
137
159
  if (command.type === "automation-points") {
138
160
  const byTick = new Map(current.map((point) => [point.tick, point]));