@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
@@ -486,17 +486,17 @@ export async function loadLiveCatalog(
486
486
  * and `chatTools()` serialized, plus `compositionBrief` of a 3-track, 48-note
487
487
  * loop; see models.test.ts, which keeps these in step with the code):
488
488
  *
489
- * - system prompt ≈ 1.9 KB, tool schemas ≈ 15.2 KB, brief ≈ 1.2 KB typical
490
- * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 18.4 KB ≈ 4,600 tokens
489
+ * - system prompt ≈ 2.8 KB, tool schemas ≈ 21.9 KB, brief ≈ 1.2 KB typical
490
+ * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 26 KB ≈ 6,500 tokens
491
491
  * per request at ~4 bytes per token;
492
492
  * - about 2 requests per prompt (tool calls, then a short reply), the second
493
493
  * also carrying the first step's tool calls and results (≈ 0.6 KB);
494
494
  * - output ≈ 450 tokens of tool-call arguments and ≈ 150 of reply.
495
495
  *
496
- * So ≈ 9,400 input and ≈ 600 output tokens per prompt.
496
+ * So ≈ 13,200 input and ≈ 600 output tokens per prompt.
497
497
  */
498
498
  export const TYPICAL_PROMPT = Object.freeze({
499
- inputTokens: 9_400,
499
+ inputTokens: 13_200,
500
500
  outputTokens: 600,
501
501
  });
502
502
 
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Agent tools for sample packs: `list_packs`, `search_sounds`, `use_sound`.
3
+ * `use_sound` resolves and fetches the sound first (a `prepare` plan), then
4
+ * commits ordinary score operations so it is one undo step like any edit.
5
+ */
6
+ import { SCORE_LIMITS, type TrackScore } from "../../core/score.ts";
7
+ import {
8
+ PackError,
9
+ PackStore,
10
+ banksOf,
11
+ type Manifest,
12
+ } from "../audio/packs.ts";
13
+
14
+ /** `RolandTR909 (TR909)`: banks with their Strudel nickname, if any. */
15
+ async function kitsWithNicknames(
16
+ packs: PackStore,
17
+ pack: string,
18
+ manifest: Manifest,
19
+ ): Promise<string[]> {
20
+ const aliases = await packs.bankAliases(pack);
21
+ return banksOf(manifest).map((bank) => {
22
+ const nickname = aliases.nicknames.get(bank);
23
+ return nickname ? `${bank} (${nickname})` : bank;
24
+ });
25
+ }
26
+ import { useSound } from "../commands/pack.ts";
27
+ import { pickWavetable, wavetableOperation } from "../commands/wavetable.ts";
28
+ import {
29
+ WARP_MODES,
30
+ WAVETABLE_PARAMS,
31
+ wavetableOf,
32
+ type ScoreOperation,
33
+ type TrackWavetable,
34
+ type WavetableParam,
35
+ } from "../../core/score.ts";
36
+ import type { AgentTool } from "./tools.ts";
37
+
38
+ /** Thrown for arguments the pack tools refuse. */
39
+ export class PackToolError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "PackToolError";
43
+ }
44
+ }
45
+
46
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
47
+
48
+ function store(context: { packs?: PackStore }): PackStore {
49
+ return context.packs ?? defaultStore();
50
+ }
51
+
52
+ let shared: PackStore | undefined;
53
+ function defaultStore(): PackStore {
54
+ shared ??= new PackStore();
55
+ return shared;
56
+ }
57
+
58
+ function bounded(value: unknown): string {
59
+ const text = JSON.stringify(value);
60
+ return text.length <= 24_000 ? text : `${text.slice(0, 24_000)}…`;
61
+ }
62
+
63
+ const wavetableParamSchema = Object.fromEntries(
64
+ (Object.keys(WAVETABLE_PARAMS) as WavetableParam[]).map((name) => [
65
+ name,
66
+ {
67
+ type: "number",
68
+ minimum: WAVETABLE_PARAMS[name][0],
69
+ maximum: WAVETABLE_PARAMS[name][1],
70
+ },
71
+ ]),
72
+ );
73
+
74
+ async function wavetableOps(
75
+ packs: PackStore,
76
+ score: TrackScore,
77
+ trackId: string,
78
+ table: string | undefined,
79
+ fields: Partial<Record<WavetableParam, number>> & { warpmode?: string },
80
+ projectRoot?: string,
81
+ ): Promise<{ operations: ScoreOperation[]; summary: string }> {
82
+ const track = score.tracks.find((item) => item.id === trackId);
83
+ let base: TrackWavetable = track
84
+ ? wavetableOf(track)
85
+ : { table: { src: "builtin:basic" } };
86
+ let summary = "wavetable";
87
+ if (table) {
88
+ // pickWavetable pins the table; take its settings, keep the score as is.
89
+ const picked = await pickWavetable(
90
+ packs,
91
+ score,
92
+ trackId,
93
+ table,
94
+ projectRoot,
95
+ );
96
+ summary = picked.summary;
97
+ const op = picked.operation;
98
+ const patched =
99
+ op.type === "addTrack"
100
+ ? op.track.wavetable
101
+ : op.type === "updateTrack"
102
+ ? op.patch.wavetable
103
+ : undefined;
104
+ if (patched) base = patched;
105
+ }
106
+ const changed = Object.entries(fields);
107
+ if (changed.length)
108
+ summary += ` · ${changed.map(([key, value]) => `${key} ${String(value)}`).join(" · ")}`;
109
+ return {
110
+ operations: [
111
+ wavetableOperation(score, trackId, {
112
+ ...base,
113
+ ...fields,
114
+ } as TrackWavetable),
115
+ ],
116
+ summary,
117
+ };
118
+ }
119
+
120
+ export const PACK_TOOLS: readonly AgentTool[] = Object.freeze([
121
+ {
122
+ name: "list_packs",
123
+ description:
124
+ "List the sample packs dawg can use (Strudel-format manifests: drum machines, Dirt-Samples, VCSL, General MIDI soundfonts, …) with license and, for one pack, its kits and sounds.",
125
+ parameters: {
126
+ type: "object",
127
+ properties: {
128
+ pack: {
129
+ type: "string",
130
+ maxLength: 64,
131
+ description: "A pack name to list its kits and sounds.",
132
+ },
133
+ },
134
+ additionalProperties: false,
135
+ },
136
+ plan(args) {
137
+ const name = typeof args.pack === "string" ? args.pack.trim() : "";
138
+ return {
139
+ kind: "action",
140
+ summary: name ? `pack ${name}` : "list packs",
141
+ run: async (context) => {
142
+ const packs = store(context);
143
+ if (!name) {
144
+ const list = await packs.list();
145
+ return {
146
+ content: bounded({
147
+ ok: true,
148
+ packs: list.map((pack) => ({
149
+ name: pack.name,
150
+ title: pack.title,
151
+ license: pack.license,
152
+ })),
153
+ hint: "search_sounds finds sounds; use_sound puts one on a track",
154
+ }),
155
+ summary: `${list.length} packs`,
156
+ };
157
+ }
158
+ const info = await packs.info(name);
159
+ if (!info)
160
+ throw new PackToolError(`no pack named ${name.slice(0, 40)}`);
161
+ const manifest = await packs.manifest(info.name);
162
+ const sounds = [...manifest.sounds]
163
+ .slice(0, 300)
164
+ .map(([sound, entry]) =>
165
+ entry.kind === "zones"
166
+ ? `${sound} (keyed, ${entry.zones.length} notes)`
167
+ : `${sound} (${entry.files.length})`,
168
+ );
169
+ return {
170
+ content: bounded({
171
+ ok: true,
172
+ pack: info.name,
173
+ license: info.license,
174
+ ...(info.attribution ? { attribution: info.attribution } : {}),
175
+ kits: await kitsWithNicknames(packs, info.name, manifest),
176
+ sounds,
177
+ total: manifest.sounds.size,
178
+ }),
179
+ summary: `${info.name} · ${manifest.sounds.size} sounds`,
180
+ };
181
+ },
182
+ };
183
+ },
184
+ },
185
+ {
186
+ name: "search_sounds",
187
+ description:
188
+ "Search pack sounds by name (e.g. 'bd', '909 hh', 'piano', 'gm guitar'). Returns pack:<pack>/<sound> refs for use_sound; keyed sounds are pitched instruments.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ query: { type: "string", minLength: 1, maxLength: 80 },
193
+ pack: { type: "string", maxLength: 64 },
194
+ limit: { type: "integer", minimum: 1, maximum: 100 },
195
+ },
196
+ required: ["query"],
197
+ additionalProperties: false,
198
+ },
199
+ plan(args) {
200
+ if (typeof args.query !== "string" || !args.query.trim())
201
+ throw new PackToolError("query must be a non-empty string");
202
+ const query = args.query.trim().slice(0, 80);
203
+ const pack = typeof args.pack === "string" ? args.pack.trim() : undefined;
204
+ const limit =
205
+ typeof args.limit === "number" && Number.isInteger(args.limit)
206
+ ? Math.max(1, Math.min(100, args.limit))
207
+ : 30;
208
+ return {
209
+ kind: "action",
210
+ summary: `search sounds ${query}`,
211
+ run: async (context) => {
212
+ const matches = await store(context).search(query, {
213
+ ...(pack ? { pack } : {}),
214
+ limit,
215
+ });
216
+ return {
217
+ content: bounded({ ok: true, matches }),
218
+ summary: `${matches.length} sounds for ${query}`,
219
+ };
220
+ },
221
+ };
222
+ },
223
+ },
224
+ {
225
+ name: "use_sound",
226
+ description:
227
+ 'Put a pack sound on a track through the sampler. sound is <pack>/<sound>[:<n>] (n picks a file, like Strudel s("bd:3")), a kit/bank name or Strudel bank nickname (909, 808, linn, TR909, tr808, sp12, dmx, RolandTR909, tidal-drum-machines/RolandTR707) to load a whole drum kit, or a keyed instrument such as gm/gm_acoustic_grand_piano or piano/piano. The file is fetched and pinned by sha256 so renders stay reproducible. Drum hits on the track keep playing the matching kit voice.',
228
+ parameters: {
229
+ type: "object",
230
+ properties: {
231
+ sound: { type: "string", minLength: 1, maxLength: 200 },
232
+ trackId: {
233
+ type: "string",
234
+ maxLength: SCORE_LIMITS.maxIdLength,
235
+ description:
236
+ "Target track id (created when missing). Defaults to the focused track.",
237
+ },
238
+ voice: {
239
+ type: "string",
240
+ maxLength: 32,
241
+ description: "Oneshot voice name (default from the sound, e.g. bd).",
242
+ },
243
+ mode: { type: "string", enum: ["auto", "keyed", "oneshot"] },
244
+ },
245
+ required: ["sound"],
246
+ additionalProperties: false,
247
+ },
248
+ plan(args, context) {
249
+ if (typeof args.sound !== "string" || !args.sound.trim())
250
+ throw new PackToolError("sound must be <pack>/<sound> or a kit name");
251
+ const sound = args.sound.trim();
252
+ const trackId =
253
+ typeof args.trackId === "string" && args.trackId
254
+ ? args.trackId
255
+ : context.focusedTrackId;
256
+ if (!ID_PATTERN.test(trackId))
257
+ throw new PackToolError(
258
+ "trackId must be 1-64 letters, digits, dot, dash, or underscore",
259
+ );
260
+ const voice = typeof args.voice === "string" ? args.voice : undefined;
261
+ const mode =
262
+ args.mode === "keyed" || args.mode === "oneshot" ? args.mode : "auto";
263
+ return {
264
+ kind: "prepare",
265
+ summary: `use ${sound}`,
266
+ run: async (action) => {
267
+ try {
268
+ const result = await useSound(
269
+ store(action),
270
+ context.score,
271
+ trackId,
272
+ sound,
273
+ {
274
+ ...(voice ? { voice } : {}),
275
+ mode,
276
+ },
277
+ );
278
+ return {
279
+ kind: "score",
280
+ operations: result.operations,
281
+ summary: result.summary,
282
+ trackId: result.trackId,
283
+ };
284
+ } catch (error) {
285
+ if (error instanceof PackError)
286
+ throw new PackToolError(error.message);
287
+ throw error;
288
+ }
289
+ },
290
+ };
291
+ },
292
+ },
293
+ {
294
+ name: "set_wavetable",
295
+ description:
296
+ "Make a track a wavetable synth and shape it (Strudel names). table: basic (sine>tri>saw>square), pwm, formant, harmonics (offline), wt_digital:0-4, wt_vgame:0-10 (Strudel uzu-wavetables), pack:<pack>/<sound>[:n], or a project table from make_wavetable (tracks/<slug>/wavetables/<name>.wav); omit to keep. wt: position 0..1. wtenv/wtattack/wtdecay/wtsustain/wtrelease: position envelope (amount -1..1, seconds). wtrate Hz/wtdepth: position LFO. warp+warpmode bend the phase. wtphaserand: start phase spread. Automate position with set_automation wt.",
297
+ parameters: {
298
+ type: "object",
299
+ properties: {
300
+ trackId: {
301
+ type: "string",
302
+ maxLength: SCORE_LIMITS.maxIdLength,
303
+ description:
304
+ "Target track (created when missing). Defaults to the focused track.",
305
+ },
306
+ table: { type: "string", minLength: 1, maxLength: 200 },
307
+ ...wavetableParamSchema,
308
+ warpmode: { type: "string", enum: [...WARP_MODES] },
309
+ },
310
+ additionalProperties: false,
311
+ },
312
+ plan(args, context) {
313
+ const trackId =
314
+ typeof args.trackId === "string" && args.trackId
315
+ ? args.trackId
316
+ : context.focusedTrackId;
317
+ if (!ID_PATTERN.test(trackId))
318
+ throw new PackToolError(
319
+ "trackId must be 1-64 letters, digits, dot, dash, or underscore",
320
+ );
321
+ const table =
322
+ typeof args.table === "string" && args.table.trim()
323
+ ? args.table.trim()
324
+ : undefined;
325
+ const fields: Partial<Record<WavetableParam, number>> & {
326
+ warpmode?: string;
327
+ } = {};
328
+ for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
329
+ const value = args[name];
330
+ if (value === undefined) continue;
331
+ const [min, max] = WAVETABLE_PARAMS[name];
332
+ if (
333
+ typeof value !== "number" ||
334
+ !Number.isFinite(value) ||
335
+ value < min ||
336
+ value > max
337
+ )
338
+ throw new PackToolError(`${name} must be ${min}..${max}`);
339
+ fields[name] = value;
340
+ }
341
+ if (args.warpmode !== undefined) {
342
+ if (!WARP_MODES.includes(args.warpmode as never))
343
+ throw new PackToolError(`warpmode must be ${WARP_MODES.join(", ")}`);
344
+ fields.warpmode = args.warpmode as string;
345
+ }
346
+ return {
347
+ kind: "prepare",
348
+ summary: table ? `wavetable ${table}` : "shape wavetable",
349
+ run: async (action) => {
350
+ try {
351
+ const result = await wavetableOps(
352
+ store(action),
353
+ context.score,
354
+ trackId,
355
+ table,
356
+ fields,
357
+ action.workspace?.root,
358
+ );
359
+ return { kind: "score", ...result, trackId };
360
+ } catch (error) {
361
+ if (error instanceof PackError)
362
+ throw new PackToolError(error.message);
363
+ throw error;
364
+ }
365
+ },
366
+ };
367
+ },
368
+ },
369
+ ]);
@@ -1,15 +1,19 @@
1
+ import { normalizeSynth } from "../../core/synth.ts";
2
+ import { normalizeFx } from "../../core/fx.ts";
1
3
  import {
2
4
  SCORE_LIMITS,
3
- AUTOMATION_LANES,
5
+ automationRange,
4
6
  isAutomationParameter,
5
7
  normalizeDelay,
6
8
  normalizeFilter,
7
9
  normalizeReverb,
8
10
  normalizeSampler,
11
+ normalizeRhythm,
9
12
  scoreFromJSON,
10
13
  type ScoreOperation,
11
14
  } from "../../core/score.ts";
12
15
  import { DRUM_VOICES } from "../../core/drums.ts";
16
+ import { synthKit } from "../../core/kits.ts";
13
17
 
14
18
  const MAX_RESPONSE_BYTES = 64 * 1024;
15
19
  const MAX_OPERATIONS = 32;
@@ -153,6 +157,17 @@ function parseOperation(value: unknown): ScoreOperation {
153
157
  safe.reverb = normalizeReverb(patch.reverb) ?? null;
154
158
  if (patch.sampler !== undefined)
155
159
  safe.sampler = normalizeSampler(patch.sampler) ?? null;
160
+ if (patch.rhythm !== undefined)
161
+ safe.rhythm = normalizeRhythm(patch.rhythm, value.trackId) ?? null;
162
+ // Only synth kit names pass; anything else leaves the kit unchanged.
163
+ if (patch.kit === null) safe.kit = null;
164
+ else if (typeof patch.kit === "string") {
165
+ const kit = synthKit(patch.kit);
166
+ if (kit) safe.kit = kit.name;
167
+ }
168
+ if (patch.fx !== undefined) safe.fx = normalizeFx(patch.fx) ?? null;
169
+ if (patch.synth !== undefined)
170
+ safe.synth = normalizeSynth(patch.synth) ?? null;
156
171
  return { type: "updateTrack", trackId: value.trackId, patch: safe };
157
172
  }
158
173
  if (
@@ -163,7 +178,7 @@ function parseOperation(value: unknown): ScoreOperation {
163
178
  Array.isArray(value.points) &&
164
179
  value.points.length <= SCORE_LIMITS.maxAutomationPoints
165
180
  ) {
166
- const { min: minValue, max: maxValue } = AUTOMATION_LANES[value.parameter];
181
+ const { min: minValue, max: maxValue } = automationRange(value.parameter);
167
182
  const points = value.points.map((candidate) => {
168
183
  if (!isRecord(candidate))
169
184
  throw new Error("agent automation point is malformed");