@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
package/src/media/cli.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * dawg media analyze <file>
8
8
  * dawg media notes <file> [--kind drums|bass|vocals|guitar|piano|other] [--from s] [--to s]
9
9
  * dawg media sample <file> <name> [--begin 0..1] [--end 0..1] [--root C4]
10
- * dawg media lyrics <file> [--lang en]
10
+ * dawg media wavetable <file> <name> [--frames 64] [--start s] [--end s] [--method auto|slice|spectral] [--smooth 0..1]
11
+ 2048-sample frames → tracks/<slug>/wavetables/<name>.wav
12
+ dawg media lyrics <file> [--lang en]
11
13
  *
12
14
  * Progress goes to stderr, the JSON result to stdout. Ctrl-C sends SIGTERM to
13
15
  * the helper (SIGKILL after 15 s) and exits 130.
@@ -32,6 +34,8 @@ export const MEDIA_HELP = `dawg media — local media tools (see DAWG.md "Media
32
34
  notes/hits + quantized snippet → <file>.<kind>.notes.json
33
35
  dawg media sample <file> <name> [--begin f] [--end f] [--root C4]
34
36
  48 kHz wav → tracks/<slug>/samples/<name>.wav + sampler snippet
37
+ dawg media wavetable <file> <name> [--frames 64] [--start s] [--end s] [--method auto|slice|spectral] [--smooth 0..1]
38
+ 2048-sample frames → tracks/<slug>/wavetables/<name>.wav
35
39
  dawg media lyrics <file> [--lang en] whisper transcript → <file>.lyrics.json/.txt
36
40
 
37
41
  Options: --track <name> picks the track folder (default main); --json prints only the result.
@@ -44,9 +48,18 @@ const VERBS: Record<string, { tool: string; positional: string[] }> = {
44
48
  notes: { tool: "transcribe_notes", positional: ["file"] },
45
49
  sample: { tool: "import_sample", positional: ["file", "name"] },
46
50
  lyrics: { tool: "transcribe_lyrics", positional: ["file"] },
51
+ wavetable: { tool: "make_wavetable", positional: ["file", "name"] },
47
52
  };
48
53
 
49
- const NUMERIC = new Set(["from", "to", "begin", "end"]);
54
+ const NUMERIC = new Set([
55
+ "from",
56
+ "to",
57
+ "begin",
58
+ "end",
59
+ "frames",
60
+ "start",
61
+ "smooth",
62
+ ]);
50
63
 
51
64
  export function parseMediaArgv(argv: readonly string[]): {
52
65
  verb: string | undefined;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The six media tools as `AgentTool`s. `plan()` validates arguments from
2
+ * The seven media tools as `AgentTool`s. `plan()` validates arguments from
3
3
  * `unknown` and returns a `media` plan whose `run()` the agent loop executes
4
4
  * with the host's `MediaHost` (project root, focused slug, runner, fetch),
5
5
  * a cancellation signal and a progress sink. `dawg media <verb>` reuses the
@@ -9,6 +9,14 @@ import type { AgentTool, ToolPlan } from "../agent/tools.ts";
9
9
  import { analyzeAudio } from "./analyze.ts";
10
10
  import { downloadAudio } from "./download.ts";
11
11
  import { importSample } from "./import.ts";
12
+ import { makeWavetableFile } from "./wavetable.ts";
13
+ import {
14
+ MAKE_MAX_FRAMES,
15
+ MAKE_METHODS,
16
+ MAKE_NORMALIZE,
17
+ type MakeMethod,
18
+ type MakeNormalize,
19
+ } from "../audio/wavetable-maker.ts";
12
20
  import { transcribeLyrics } from "./lyrics.ts";
13
21
  import { NOTE_KINDS, transcribeNotes, type NoteKind } from "./notes.ts";
14
22
  import { MAX_NAME_LENGTH, trackSlug } from "./paths.ts";
@@ -259,6 +267,103 @@ export const MEDIA_TOOLS: readonly AgentTool[] = Object.freeze([
259
267
  );
260
268
  },
261
269
  },
270
+ {
271
+ name: "make_wavetable",
272
+ description:
273
+ "Make a wavetable from any audio file in the project (download, stem, imported sample): writes tracks/<slug>/wavetables/<name>.wav (float32, 2048-sample frames) and reports the detected pitch, method and how the timbre moves across the frames. Region defaults to the most stable tonal 2 s; method auto picks slice (pitched single cycles) or spectral (vocals, pads, noise). Then set_wavetable with that path as table.",
274
+ parameters: {
275
+ type: "object",
276
+ properties: {
277
+ file: fileSchema,
278
+ name: {
279
+ type: "string",
280
+ maxLength: MAX_NAME_LENGTH,
281
+ description: "Table name: lowercase identifier such as vox or growl.",
282
+ },
283
+ frames: {
284
+ type: "integer",
285
+ minimum: 1,
286
+ maximum: MAKE_MAX_FRAMES,
287
+ description: "Frame count (default 64).",
288
+ },
289
+ start: {
290
+ type: "number",
291
+ minimum: 0,
292
+ description: "Region start, seconds.",
293
+ },
294
+ end: {
295
+ type: "number",
296
+ minimum: 0,
297
+ description: "Region end, seconds.",
298
+ },
299
+ method: { type: "string", enum: [...MAKE_METHODS] },
300
+ smooth: {
301
+ type: "number",
302
+ minimum: 0,
303
+ maximum: 1,
304
+ description: "Blend frames with neighbours for a smoother morph.",
305
+ },
306
+ normalize: { type: "string", enum: [...MAKE_NORMALIZE] },
307
+ },
308
+ required: ["file", "name"],
309
+ additionalProperties: false,
310
+ },
311
+ plan: (args) => {
312
+ const file = text(args, "file", 1024);
313
+ const name = sampleName(args);
314
+ const frames = args.frames;
315
+ if (
316
+ frames !== undefined &&
317
+ (typeof frames !== "number" ||
318
+ !Number.isInteger(frames) ||
319
+ frames < 1 ||
320
+ frames > MAKE_MAX_FRAMES)
321
+ )
322
+ throw new MediaArgumentError(
323
+ `frames must be an integer 1..${MAKE_MAX_FRAMES}`,
324
+ );
325
+ const seconds = (key: "start" | "end") => {
326
+ const value = args[key];
327
+ if (value === undefined) return undefined;
328
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
329
+ throw new MediaArgumentError(`${key} must be seconds ≥ 0`);
330
+ return value;
331
+ };
332
+ const start = seconds("start");
333
+ const end = seconds("end");
334
+ if (start !== undefined && end !== undefined && end <= start)
335
+ throw new MediaArgumentError("end must be greater than start");
336
+ const method = optionalText(args, "method", 16);
337
+ if (method !== undefined && !MAKE_METHODS.includes(method as MakeMethod))
338
+ throw new MediaArgumentError(
339
+ `method must be ${MAKE_METHODS.join(", ")}`,
340
+ );
341
+ const normalize = optionalText(args, "normalize", 16);
342
+ if (
343
+ normalize !== undefined &&
344
+ !MAKE_NORMALIZE.includes(normalize as MakeNormalize)
345
+ )
346
+ throw new MediaArgumentError(
347
+ `normalize must be ${MAKE_NORMALIZE.join(", ")}`,
348
+ );
349
+ const smooth = optionalFraction(args, "smooth");
350
+ return media(`make wavetable ${name} from ${file}`, (context) =>
351
+ makeWavetableFile(
352
+ {
353
+ file,
354
+ name,
355
+ ...(frames !== undefined ? { frames: frames as number } : {}),
356
+ ...(start !== undefined ? { start } : {}),
357
+ ...(end !== undefined ? { end } : {}),
358
+ ...(method ? { method: method as MakeMethod } : {}),
359
+ ...(smooth !== undefined ? { smooth } : {}),
360
+ ...(normalize ? { normalize: normalize as MakeNormalize } : {}),
361
+ },
362
+ context,
363
+ ),
364
+ );
365
+ },
366
+ },
262
367
  {
263
368
  name: "transcribe_lyrics",
264
369
  description:
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `make_wavetable(file, name, {...})`: turns any audio file in the project
3
+ * (a download, a stem, an imported sample) into a wavetable at
4
+ * `tracks/<slug>/wavetables/<name>.wav` — float32 frames of 2048 samples
5
+ * with a `clm ` chunk, the format Serum, Vital and uzu-wavetables use — and
6
+ * reports how the timbre moves across it so the model can pick a position.
7
+ *
8
+ * WAV inputs are read directly; anything else goes through ffmpeg to
9
+ * 48 kHz mono float. The extraction itself is `src/audio/wavetable-maker.ts`.
10
+ */
11
+ import { rename, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { SCORE_LIMITS } from "../../core/score.ts";
14
+ import {
15
+ MAKE_FRAME_SIZE,
16
+ WavetableMakeError,
17
+ encodeWavetableWav,
18
+ makeWavetable,
19
+ type MakeMethod,
20
+ type MakeNormalize,
21
+ } from "../audio/wavetable-maker.ts";
22
+ import { missingTool } from "./backend.ts";
23
+ import {
24
+ ensureDir,
25
+ formatBytes,
26
+ projectPath,
27
+ resolveInput,
28
+ sha256File,
29
+ } from "./paths.ts";
30
+ import { MediaToolError, runHelper } from "./process.ts";
31
+ import {
32
+ MEDIA_LIMITS,
33
+ type MediaHost,
34
+ type MediaResult,
35
+ type MediaRunContext,
36
+ } from "./types.ts";
37
+ import { parseWav } from "./vendor/wav.ts";
38
+
39
+ /** Longest source read; the maker only analyses a bounded region of it. */
40
+ const MAX_SOURCE_SECONDS = 10 * 60;
41
+ const DECODE_RATE = 48_000;
42
+
43
+ export type MakeWavetableArgs = Readonly<{
44
+ file: string;
45
+ name: string;
46
+ frames?: number;
47
+ start?: number;
48
+ end?: number;
49
+ method?: MakeMethod;
50
+ smooth?: number;
51
+ normalize?: MakeNormalize;
52
+ }>;
53
+
54
+ export function wavetablesDir(
55
+ host: Pick<MediaHost, "projectRoot" | "trackSlug">,
56
+ ): string {
57
+ return join(host.projectRoot, "tracks", host.trackSlug, "wavetables");
58
+ }
59
+
60
+ function mixdown(
61
+ wav: ReturnType<typeof parseWav>,
62
+ maxFrames: number,
63
+ ): Float32Array {
64
+ const frames = Math.min(wav.sampleCount, maxFrames);
65
+ const out = new Float32Array(frames);
66
+ for (let i = 0; i < frames; i += 1) {
67
+ let sum = 0;
68
+ for (let c = 0; c < wav.channels; c += 1) sum += wav.sample(i, c);
69
+ out[i] = sum / wav.channels;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ async function readSource(
75
+ context: MediaRunContext,
76
+ absolute: string,
77
+ size: number,
78
+ ): Promise<{ mono: Float32Array; sampleRate: number }> {
79
+ const options = {
80
+ maximumBytes: 2 ** 31,
81
+ maximumDurationSeconds: 24 * 3600,
82
+ maximumChannels: 32,
83
+ };
84
+ if (/\.wav$/i.test(absolute) && size <= MEDIA_LIMITS.downloadMaxBytes) {
85
+ try {
86
+ const wav = parseWav(
87
+ new Uint8Array(await Bun.file(absolute).arrayBuffer()),
88
+ options,
89
+ );
90
+ return {
91
+ mono: mixdown(wav, MAX_SOURCE_SECONDS * wav.sampleRate),
92
+ sampleRate: wav.sampleRate,
93
+ };
94
+ } catch {
95
+ // Unusual WAV (ADPCM, extensible quirks): let ffmpeg read it.
96
+ }
97
+ }
98
+ if (!context.runner.which("ffmpeg"))
99
+ throw new MediaToolError(missingTool("ffmpeg"));
100
+ const temp = join(
101
+ await ensureDir(wavetablesDir(context)),
102
+ `.decode-${process.pid}-${Date.now()}.part.wav`,
103
+ );
104
+ context.progress("ffmpeg → mono 48 kHz");
105
+ try {
106
+ await runHelper(
107
+ context,
108
+ [
109
+ "ffmpeg",
110
+ "-hide_banner",
111
+ "-nostdin",
112
+ "-loglevel",
113
+ "error",
114
+ "-y",
115
+ "-i",
116
+ absolute,
117
+ "-vn",
118
+ "-t",
119
+ String(MAX_SOURCE_SECONDS),
120
+ "-ar",
121
+ String(DECODE_RATE),
122
+ "-ac",
123
+ "1",
124
+ "-c:a",
125
+ "pcm_f32le",
126
+ temp,
127
+ ],
128
+ { timeoutMs: MEDIA_LIMITS.sampleTimeoutMs },
129
+ );
130
+ const wav = parseWav(
131
+ new Uint8Array(await Bun.file(temp).arrayBuffer()),
132
+ options,
133
+ );
134
+ return { mono: mixdown(wav, wav.sampleCount), sampleRate: wav.sampleRate };
135
+ } finally {
136
+ await Bun.file(temp)
137
+ .delete()
138
+ .catch(() => undefined);
139
+ }
140
+ }
141
+
142
+ export async function makeWavetableFile(
143
+ args: MakeWavetableArgs,
144
+ context: MediaRunContext,
145
+ ): Promise<MediaResult> {
146
+ const input = await resolveInput(context, args.file);
147
+ const { mono, sampleRate } = await readSource(
148
+ context,
149
+ input.absolute,
150
+ input.size,
151
+ );
152
+ context.progress(`analysing ${input.relative}`);
153
+ let made;
154
+ try {
155
+ made = makeWavetable(mono, sampleRate, {
156
+ ...(args.frames !== undefined ? { frames: args.frames } : {}),
157
+ ...(args.start !== undefined ? { start: args.start } : {}),
158
+ ...(args.end !== undefined ? { end: args.end } : {}),
159
+ ...(args.method !== undefined ? { method: args.method } : {}),
160
+ ...(args.smooth !== undefined ? { smooth: args.smooth } : {}),
161
+ ...(args.normalize !== undefined ? { normalize: args.normalize } : {}),
162
+ });
163
+ } catch (error) {
164
+ if (error instanceof WavetableMakeError)
165
+ throw new MediaToolError(error.message);
166
+ throw error;
167
+ }
168
+ const bytes = encodeWavetableWav(made.frames);
169
+ if (bytes.byteLength > SCORE_LIMITS.maxSampleFileBytes)
170
+ throw new MediaToolError(
171
+ `${formatBytes(bytes.byteLength)} is over the sample file limit; use fewer frames`,
172
+ );
173
+ const dir = await ensureDir(wavetablesDir(context));
174
+ const target = join(dir, `${args.name}.wav`);
175
+ const temp = `${target}.part`;
176
+ await Bun.write(temp, bytes);
177
+ await rename(temp, target);
178
+ const sha256 = await sha256File(target);
179
+ const path = projectPath(context.projectRoot, target);
180
+ const size = (await stat(target)).size;
181
+ const region = `${made.region.start.toFixed(2)}–${made.region.end.toFixed(2)} s${made.region.auto ? " (auto)" : ""}`;
182
+ return {
183
+ summary: `wavetable ${args.name} · ${made.frames.length} frames · ${made.method} · ${region}`,
184
+ outputs: [path],
185
+ content: {
186
+ wavetable: {
187
+ name: args.name,
188
+ path,
189
+ sha256,
190
+ frames: made.frames.length,
191
+ frameSize: MAKE_FRAME_SIZE,
192
+ bytes: size,
193
+ source: input.relative,
194
+ method: made.method,
195
+ region: made.region,
196
+ ...(made.pitch ? { pitch: made.pitch } : {}),
197
+ },
198
+ sweep: made.sweep,
199
+ next: `set_wavetable with table "${path}" plays it on a track (wt 0..1 picks the frame; wtenv or set_automation wt sweeps it). In track.ts: instrument: wavetable("./wavetables/${args.name}.wav").`,
200
+ },
201
+ };
202
+ }
package/src/render.ts CHANGED
@@ -12,6 +12,13 @@ import { scoreFromJSON, type TrackScore } from "../core/score.ts";
12
12
  import { decodeLoop } from "../core/loop.ts";
13
13
  import { renderScoreWav } from "./audio/wav.ts";
14
14
  import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
15
+ import {
16
+ PackStore,
17
+ creditsLine,
18
+ packCredits,
19
+ withWavComment,
20
+ writeCredits,
21
+ } from "./audio/packs.ts";
15
22
  import { evaluateProject, formatDiagnostic } from "../core/sdk/eval.ts";
16
23
  import { isProject } from "./project/init.ts";
17
24
  import { resolveSessionArg } from "./session/attach.ts";
@@ -76,13 +83,27 @@ export async function runRenderCommand(
76
83
  for (const problem of samples.problems)
77
84
  stderr.write(`sample ${problem.level} · ${problem.message}\n`);
78
85
  }
79
- const wav = renderScoreWav(score, { samples });
86
+ let wav = renderScoreWav(score, { samples });
87
+ // Pack sounds: name the packs (and CC-BY attributions) in the WAV's INFO
88
+ // comment and on stdout; CREDITS.md in a project keeps the attributions.
89
+ const refs = score.tracks.flatMap((track) =>
90
+ Object.values(track.sampler?.voices ?? {}),
91
+ );
92
+ let credits: string | undefined;
93
+ if (refs.some((ref) => ref.src.startsWith("pack:"))) {
94
+ const list = packCredits(refs, await new PackStore().list());
95
+ credits = creditsLine(list);
96
+ if (credits) wav = withWavComment(wav, `samples: ${credits}`);
97
+ if (await isProject(workspace).catch(() => false))
98
+ await writeCredits(workspace, list).catch(() => undefined);
99
+ }
80
100
  const path = resolve(workspace, target);
81
101
  const temporary = `${path}.${process.pid}.tmp`;
82
102
  await writeFile(temporary, wav);
83
103
  await rename(temporary, path);
84
104
  const sha = createHash("sha256").update(wav).digest("hex");
85
105
  stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
106
+ if (credits) stdout.write(`credits · ${credits}\n`);
86
107
  return 0;
87
108
  }
88
109
 
@@ -89,13 +89,15 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
89
89
  if (track.filter) effects.add("filter");
90
90
  if (track.delay) effects.add("delay");
91
91
  if (track.reverb) effects.add("reverb");
92
+ for (const name of Object.keys(track.fx ?? {})) effects.add(name);
92
93
  if (track.volumeAutomation.length || track.panAutomation.length)
93
94
  effects.add("automation");
94
95
  if (
95
96
  track.filterAutomation?.length ||
96
97
  track.resonanceAutomation?.length ||
97
98
  track.delayFeedbackAutomation?.length ||
98
- track.delayMixAutomation?.length
99
+ track.delayMixAutomation?.length ||
100
+ Object.keys(track.fxAutomation ?? {}).length
99
101
  )
100
102
  effects.add("sweep");
101
103
  const instrument = track.instrument.toLowerCase().slice(0, 24);