@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,270 @@
1
+ /**
2
+ * `preview_sound`: render a short snippet of a track, or of a candidate
3
+ * change to it, without committing anything. The candidate is a list of
4
+ * ordinary sound tool calls (`set_fx`, `set_synth`, `set_wavetable`, …),
5
+ * planned by those same tools against a copy of the score, so what the
6
+ * agent hears is exactly what committing them would produce.
7
+ *
8
+ * The snippet is the audition loop's score (`previewScore`): the track's
9
+ * own notes over at most four bars, or a short phrase by role. It renders
10
+ * through the normal renderer, so every effect, bus and impulse response
11
+ * sounds as in the song. The result carries loudness, peak and spectral
12
+ * centroid for the committed and the candidate sound, and a one-line
13
+ * description. When a TUI is attached the host plays the candidate once.
14
+ */
15
+ import { applyScoreOperations } from "../../core/diff.ts";
16
+ import { SCORE_LIMITS, type TrackScore } from "../../core/score.ts";
17
+ import {
18
+ MAX_PREVIEW_BARS,
19
+ analyzePcm,
20
+ describeSound,
21
+ previewScore,
22
+ type SoundStats,
23
+ } from "../audio/preview.ts";
24
+ import { renderScorePcm, type RenderedAudio } from "../audio/wav.ts";
25
+ import type {
26
+ ActionContext,
27
+ AgentTool,
28
+ ToolContext,
29
+ ToolPlan,
30
+ } from "./tools.ts";
31
+
32
+ /** Tools a candidate may use: the ones that change how a track sounds. */
33
+ export const PREVIEWABLE_TOOLS: readonly string[] = Object.freeze([
34
+ "set_instrument",
35
+ "set_mix",
36
+ "set_automation",
37
+ "set_fx",
38
+ "set_synth",
39
+ "set_sample",
40
+ "set_effects",
41
+ "set_wavetable",
42
+ "set_drum_kit",
43
+ "use_sound",
44
+ ]);
45
+
46
+ const MAX_CHANGES = 8;
47
+
48
+ /** How the host renders and plays a preview; both optional. */
49
+ export type PreviewHost = Readonly<{
50
+ /** Render a preview score (the host adds decoded samples); default in-thread. */
51
+ render?: (score: TrackScore) => Promise<RenderedAudio> | RenderedAudio;
52
+ /** Play the snippet once; returns false when nothing could sound. */
53
+ play?: (audio: RenderedAudio) => boolean | Promise<boolean>;
54
+ }>;
55
+
56
+ /** Thrown for a candidate the tool refuses; nothing is rendered. */
57
+ export class PreviewToolError extends Error {
58
+ constructor(message: string) {
59
+ super(message);
60
+ this.name = "PreviewToolError";
61
+ }
62
+ }
63
+
64
+ type Change = Readonly<{ tool: string; args: Record<string, unknown> }>;
65
+
66
+ function parseChanges(value: unknown): Change[] {
67
+ if (value === undefined) return [];
68
+ if (!Array.isArray(value))
69
+ throw new PreviewToolError("changes must be a list");
70
+ if (value.length > MAX_CHANGES)
71
+ throw new PreviewToolError(`at most ${MAX_CHANGES} changes`);
72
+ return value.map((entry, index) => {
73
+ const item = entry as { tool?: unknown; args?: unknown } | null;
74
+ if (!item || typeof item.tool !== "string")
75
+ throw new PreviewToolError(`changes[${index}].tool is required`);
76
+ if (!PREVIEWABLE_TOOLS.includes(item.tool))
77
+ throw new PreviewToolError(
78
+ `changes[${index}]: ${item.tool} cannot be previewed; use ${PREVIEWABLE_TOOLS.join(", ")}`,
79
+ );
80
+ const args =
81
+ item.args === undefined
82
+ ? {}
83
+ : typeof item.args === "object" && !Array.isArray(item.args)
84
+ ? (item.args as Record<string, unknown>)
85
+ : undefined;
86
+ if (!args)
87
+ throw new PreviewToolError(`changes[${index}].args must be an object`);
88
+ return { tool: item.tool, args };
89
+ });
90
+ }
91
+
92
+ /** Run each change's own plan on a copy; returns the candidate score. */
93
+ async function applyChanges(
94
+ changes: readonly Change[],
95
+ context: ToolContext,
96
+ action: ActionContext,
97
+ lookup: (name: string) => AgentTool | undefined,
98
+ ): Promise<{ score: TrackScore; summaries: string[] }> {
99
+ let score = context.score;
100
+ const summaries: string[] = [];
101
+ for (const change of changes) {
102
+ const tool = lookup(change.tool);
103
+ if (!tool) throw new PreviewToolError(`unknown tool ${change.tool}`);
104
+ let plan: ToolPlan = tool.plan(change.args, { ...context, score });
105
+ if (plan.kind === "prepare") plan = await plan.run(action);
106
+ if (plan.kind !== "score")
107
+ throw new PreviewToolError(`${change.tool} does not change the score`);
108
+ score = applyScoreOperations(score, plan.operations);
109
+ summaries.push(plan.summary);
110
+ }
111
+ return { score, summaries };
112
+ }
113
+
114
+ function round(stats: SoundStats) {
115
+ return {
116
+ rmsDb: Math.round(stats.rmsDb * 10) / 10,
117
+ peakDb: Math.round(stats.peakDb * 10) / 10,
118
+ centroidHz: Math.round(stats.centroidHz),
119
+ clipped: stats.clipped,
120
+ description: describeSound(stats),
121
+ };
122
+ }
123
+
124
+ /** One clause comparing two renders: louder/quieter, brighter/darker. */
125
+ export function compareSounds(before: SoundStats, after: SoundStats): string {
126
+ const parts: string[] = [];
127
+ const level = after.rmsDb - before.rmsDb;
128
+ if (Math.abs(level) >= 0.5)
129
+ parts.push(
130
+ `${Math.abs(level).toFixed(1)} dB ${level > 0 ? "louder" : "quieter"}`,
131
+ );
132
+ if (before.centroidHz > 0 && after.centroidHz > 0) {
133
+ const ratio = after.centroidHz / before.centroidHz;
134
+ if (ratio >= 1.08) parts.push(`brighter (×${ratio.toFixed(2)} centroid)`);
135
+ else if (ratio <= 1 / 1.08)
136
+ parts.push(`darker (×${ratio.toFixed(2)} centroid)`);
137
+ }
138
+ if (after.clipped > 0 && before.clipped === 0) parts.push("now clips");
139
+ return parts.length ? parts.join(", ") : "about the same level and tone";
140
+ }
141
+
142
+ export function previewSoundTool(
143
+ lookup: (name: string) => AgentTool | undefined,
144
+ ): AgentTool {
145
+ return {
146
+ name: "preview_sound",
147
+ description:
148
+ "Hear a track, or a candidate sound change, without committing it. changes: sound tool calls to try ({tool, args} for set_fx, set_synth, set_wavetable, set_instrument, set_sample, set_effects, set_mix, set_automation, set_drum_kit, use_sound), applied to a copy. Renders the track's notes over up to 4 bars (or a short phrase by role when it has none), solo or in context, and returns RMS/peak dBFS, spectral centroid and a one-line description for the current and the candidate sound. When the user's window is open it plays the snippet once. Then commit with the normal tools if it sounds right.",
149
+ parameters: {
150
+ type: "object",
151
+ properties: {
152
+ trackId: {
153
+ type: "string",
154
+ maxLength: SCORE_LIMITS.maxIdLength,
155
+ description: "Track to hear. Defaults to the focused track.",
156
+ },
157
+ changes: {
158
+ type: "array",
159
+ maxItems: MAX_CHANGES,
160
+ items: {
161
+ type: "object",
162
+ properties: {
163
+ tool: { type: "string", enum: [...PREVIEWABLE_TOOLS] },
164
+ args: { type: "object" },
165
+ },
166
+ required: ["tool"],
167
+ },
168
+ },
169
+ bars: { type: "integer", minimum: 1, maximum: MAX_PREVIEW_BARS },
170
+ context: {
171
+ type: "boolean",
172
+ description: "Hear the whole mix with the track (default: solo).",
173
+ },
174
+ play: {
175
+ type: "boolean",
176
+ description: "Play it in the user's window (default true).",
177
+ },
178
+ },
179
+ additionalProperties: false,
180
+ },
181
+ plan(args, context) {
182
+ const trackId =
183
+ typeof args.trackId === "string" && args.trackId
184
+ ? args.trackId
185
+ : context.focusedTrackId;
186
+ const changes = parseChanges(args.changes);
187
+ // A candidate may create the track (set_instrument on a new id).
188
+ if (
189
+ changes.length === 0 &&
190
+ !context.score.tracks.some((track) => track.id === trackId)
191
+ )
192
+ throw new PreviewToolError(`unknown track ${trackId}`);
193
+ const bars =
194
+ typeof args.bars === "number" && Number.isInteger(args.bars)
195
+ ? Math.max(1, Math.min(MAX_PREVIEW_BARS, args.bars))
196
+ : undefined;
197
+ const inContext = args.context === true;
198
+ const play = args.play !== false;
199
+ return {
200
+ kind: "action",
201
+ summary: changes.length
202
+ ? `preview ${trackId} · ${changes.map((change) => change.tool).join(", ")}`
203
+ : `preview ${trackId}`,
204
+ run: async (action) => {
205
+ const candidate = await applyChanges(
206
+ changes,
207
+ { ...context, focusedTrackId: trackId },
208
+ action,
209
+ lookup,
210
+ );
211
+ const after = previewScore(candidate.score, trackId, {
212
+ context: inContext,
213
+ });
214
+ if (!after) throw new PreviewToolError(`unknown track ${trackId}`);
215
+ const region = bars
216
+ ? { startBar: after.region.startBar, bars }
217
+ : after.region;
218
+ const render =
219
+ action.preview?.render ?? ((value) => renderScorePcm(value));
220
+ const snippet = async (value: TrackScore) => {
221
+ const preview = previewScore(value, trackId, {
222
+ context: inContext,
223
+ region,
224
+ });
225
+ if (!preview) return undefined;
226
+ const audio = await render(preview.score);
227
+ return { audio, stats: analyzePcm(audio.pcm, audio.sampleRate) };
228
+ };
229
+ const next = await snippet(candidate.score);
230
+ if (!next) throw new PreviewToolError(`unknown track ${trackId}`);
231
+ const before =
232
+ changes.length > 0 &&
233
+ context.score.tracks.some((track) => track.id === trackId)
234
+ ? await snippet(context.score)
235
+ : undefined;
236
+ let played = false;
237
+ if (play && action.preview?.play)
238
+ played = await action.preview.play(next.audio);
239
+ const describe = describeSound(next.stats);
240
+ const comparison = before
241
+ ? compareSounds(before.stats, next.stats)
242
+ : undefined;
243
+ const content = JSON.stringify({
244
+ ok: true,
245
+ committed: false,
246
+ trackId,
247
+ bars: region.bars,
248
+ fromBar: region.startBar + 1,
249
+ source: after.source,
250
+ ...(after.role ? { phrase: after.role } : {}),
251
+ mode: inContext ? "in context" : "solo",
252
+ ...(candidate.summaries.length
253
+ ? { candidate: candidate.summaries }
254
+ : {}),
255
+ sound: round(next.stats),
256
+ ...(before ? { current: round(before.stats), comparison } : {}),
257
+ played,
258
+ next: changes.length
259
+ ? "commit with the same tool calls if it sounds right"
260
+ : undefined,
261
+ });
262
+ return {
263
+ content,
264
+ summary: `${played ? "♪ " : ""}preview ${trackId} · ${comparison ?? describe}`,
265
+ };
266
+ },
267
+ };
268
+ },
269
+ };
270
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Agent tools for generated drum rhythms (`core/euclid.ts`, `core/rhythm.ts`).
3
+ *
4
+ * `set_rhythm` stores Euclidean/T-1 parameters on the track and lets the
5
+ * score regenerate the voice's notes, so the agent edits four numbers
6
+ * instead of sixteen notes and later edits stay one small diff.
7
+ */
8
+ import { diffScores } from "../../core/diff.ts";
9
+ import {
10
+ RHYTHM_LIMITS,
11
+ normalizeRhythmRow,
12
+ type RhythmRow,
13
+ } from "../../core/euclid.ts";
14
+ import {
15
+ removeRhythmRow,
16
+ rhythmVoicePitch,
17
+ setRhythmRow,
18
+ } from "../../core/rhythm.ts";
19
+ import { SCORE_LIMITS, ScoreValidationError } from "../../core/score.ts";
20
+ import type { AgentTool, ToolContext } from "./tools.ts";
21
+ import { ToolArgumentError } from "./tools.ts";
22
+
23
+ const int = { type: "integer" } as const;
24
+ const num = { type: "number" } as const;
25
+ /** Divisions are checked by `normalizeRhythmRow`; the enum stays out of the catalog. */
26
+ const division = { type: "string" } as const;
27
+
28
+ /** Compact on purpose: ranges are checked by `normalizeRhythmRow`. */
29
+ const ROW_FIELDS = Object.freeze({
30
+ voice: { type: "string" },
31
+ pulses: int,
32
+ steps: int,
33
+ rotate: int,
34
+ division,
35
+ grid: { type: "string" },
36
+ repeats: int,
37
+ time: division,
38
+ pace: num,
39
+ ramp: num,
40
+ velocity: num,
41
+ accent: num,
42
+ accents: int,
43
+ gate: num,
44
+ legato: { type: "boolean" },
45
+ probability: num,
46
+ seed: int,
47
+ swing: num,
48
+ nudge: num,
49
+ cycles: { type: "array", items: { type: "object" } },
50
+ });
51
+
52
+ function trackOf(args: Record<string, unknown>, context: ToolContext) {
53
+ const trackId = args.trackId ?? context.focusedTrackId;
54
+ if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
55
+ throw new ToolArgumentError("trackId must be a short string");
56
+ const track = context.score.tracks.find((t) => t.id === trackId);
57
+ if (!track)
58
+ throw new ToolArgumentError(
59
+ `unknown track ${trackId}; create it with create_track first`,
60
+ );
61
+ return track;
62
+ }
63
+
64
+ function rowFrom(value: unknown, label: string): RhythmRow {
65
+ try {
66
+ return normalizeRhythmRow(value, label);
67
+ } catch (error) {
68
+ throw new ToolArgumentError(
69
+ error instanceof Error ? error.message : String(error),
70
+ );
71
+ }
72
+ }
73
+
74
+ export const RHYTHM_TOOLS: readonly AgentTool[] = Object.freeze([
75
+ {
76
+ name: "set_rhythm",
77
+ description:
78
+ "Preferred for drums: one row per voice of a kit/oneshot track; its notes regenerate from the row. pulses (4) over steps (16..64) Euclidean, rotate later; division 1/32..1/1 (1/16); grid 'x.X.' explicit (X accent); repeats 0..16 after each pulse every time, pace -1..1, ramp -1..1; velocity (0.8); accent 0..1 on E(accents,pulses); gate; legato; probability+seed; swing/nudge ±0.5 step; cycles [{pulses,rotate,repeats,probability,velocity}] per pass. remove [voices] (freeze keeps notes).",
79
+ parameters: {
80
+ type: "object",
81
+ properties: {
82
+ trackId: { type: "string" },
83
+ rows: {
84
+ type: "array",
85
+ maxItems: RHYTHM_LIMITS.maxRows,
86
+ items: {
87
+ type: "object",
88
+ properties: ROW_FIELDS,
89
+ required: ["voice"],
90
+ additionalProperties: false,
91
+ },
92
+ },
93
+ remove: {
94
+ type: "array",
95
+ maxItems: RHYTHM_LIMITS.maxRows,
96
+ items: { type: "string" },
97
+ },
98
+ freeze: { type: "boolean" },
99
+ },
100
+ additionalProperties: false,
101
+ },
102
+ plan(args, context) {
103
+ const track = trackOf(args, context);
104
+ const rows = Array.isArray(args.rows) ? args.rows : [];
105
+ const remove = Array.isArray(args.remove) ? args.remove : [];
106
+ if (rows.length === 0 && remove.length === 0)
107
+ throw new ToolArgumentError("set_rhythm needs rows or remove");
108
+ if (rows.length > RHYTHM_LIMITS.maxRows)
109
+ throw new ToolArgumentError(
110
+ `at most ${RHYTHM_LIMITS.maxRows} rows per call`,
111
+ );
112
+ let next = context.score;
113
+ const parts: string[] = [];
114
+ try {
115
+ for (const voice of remove) {
116
+ if (typeof voice !== "string")
117
+ throw new ToolArgumentError("remove holds voice names");
118
+ next = removeRhythmRow(next, track.id, voice, args.freeze === true);
119
+ parts.push(`-${voice}`);
120
+ }
121
+ for (const [index, value] of rows.entries()) {
122
+ const row = rowFrom(value, `rows[${index}]`);
123
+ if (rhythmVoicePitch(track, row.voice) === undefined)
124
+ throw new ToolArgumentError(
125
+ `track ${track.id} has no voice "${row.voice}"`,
126
+ );
127
+ next = setRhythmRow(next, track.id, row);
128
+ parts.push(
129
+ `${row.voice} ${row.grid ? `grid ${row.grid}` : `E(${row.pulses ?? 4},${row.steps ?? 16}${row.rotate ? `,${row.rotate}` : ""})`}`,
130
+ );
131
+ }
132
+ } catch (error) {
133
+ if (error instanceof ScoreValidationError)
134
+ throw new ToolArgumentError(error.message);
135
+ throw error;
136
+ }
137
+ return {
138
+ kind: "score",
139
+ operations: diffScores(context.score, next),
140
+ trackId: track.id,
141
+ summary: `${track.id} ${parts.join(", ")}`,
142
+ };
143
+ },
144
+ },
145
+ ]);