@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.
- package/CHANGELOG.md +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
type ScoreOperation,
|
|
4
4
|
type TrackScore,
|
|
5
5
|
} from "../../core/score.ts";
|
|
6
|
+
import { CHORD_PROCESS } from "../../core/chords.ts";
|
|
6
7
|
import type { MediaServices } from "../media/types.ts";
|
|
8
|
+
import type { PackStore } from "../audio/packs.ts";
|
|
9
|
+
import type { PreviewHost } from "./preview-tool.ts";
|
|
7
10
|
import { compositionBrief } from "./brief.ts";
|
|
8
11
|
import {
|
|
9
12
|
GatewayError,
|
|
@@ -12,6 +15,8 @@ import {
|
|
|
12
15
|
type GatewayClient,
|
|
13
16
|
type GatewayModel,
|
|
14
17
|
} from "./gateway.ts";
|
|
18
|
+
import { diffScores } from "../../core/diff.ts";
|
|
19
|
+
import { reconcileRhythm } from "../../core/rhythm.ts";
|
|
15
20
|
import { validateAgentOperation } from "./planner.ts";
|
|
16
21
|
import { SseBudgetError } from "./sse.ts";
|
|
17
22
|
import {
|
|
@@ -155,6 +160,10 @@ export type AgentHost = Readonly<{
|
|
|
155
160
|
web?: WebHost;
|
|
156
161
|
/** Runner and env for the media tools (root and slug come from `workspace`); absent → rejected. */
|
|
157
162
|
media?: MediaServices;
|
|
163
|
+
/** Sample packs for the pack tools; default the user cache. */
|
|
164
|
+
packs?: PackStore;
|
|
165
|
+
/** How preview_sound renders and plays (the TUI plays it once). */
|
|
166
|
+
preview?: PreviewHost;
|
|
158
167
|
}>;
|
|
159
168
|
|
|
160
169
|
export type AgentTurnOptions = Readonly<{
|
|
@@ -181,15 +190,20 @@ export const WORKSPACE_PROMPT = [
|
|
|
181
190
|
].join(" ");
|
|
182
191
|
/** Shared by both agent loops: how the media tools fit the composition flow. */
|
|
183
192
|
export const MEDIA_PROMPT =
|
|
184
|
-
"Media tools (download_audio, split_stems, analyze_audio, transcribe_notes, import_sample, transcribe_lyrics) work on files under tracks/<slug>/downloads/ and report project-relative paths; the brief's project tree lists what is already there (read_file reports a wav's type and size), so never download the same video twice. They can run for minutes, so call them one at a time and chain on their outputs (download → stems → analyze → notes).";
|
|
193
|
+
"Media tools (download_audio, split_stems, analyze_audio, transcribe_notes, import_sample, make_wavetable, transcribe_lyrics) work on files under tracks/<slug>/downloads/ and report project-relative paths; the brief's project tree lists what is already there (read_file reports a wav's type and size), so never download the same video twice. They can run for minutes, so call them one at a time and chain on their outputs (download → stems → analyze → notes). make_wavetable turns a download, stem or sample into tracks/<slug>/wavetables/<name>.wav and describes its sweep; play it with set_wavetable table <that path>.";
|
|
185
194
|
|
|
186
195
|
export const AGENT_SYSTEM_PROMPT = [
|
|
187
196
|
"You are dawg, a loop composer inside a terminal music workstation.",
|
|
188
197
|
"Edit the score only by calling the provided tools; every call is validated and applied immediately, and its result tells you the new revision.",
|
|
189
198
|
"Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
|
|
190
199
|
"Prefer a few well-formed calls (one add_notes call per track part) over many tiny ones.",
|
|
191
|
-
'For drums, create a track with instrument "kit" and use add_drums
|
|
192
|
-
|
|
200
|
+
'For drums, create a track with instrument "kit" and prefer set_rhythm (Euclidean rows: pulses over steps, rotate, repeats for rolls, accent, probability, swing) so the beat stays editable as parameters; for a genre groove start from apply_drum_pattern (list_drum_patterns) and pick a sound with set_drum_kit; use add_drums only for one-off fills. Drum pitches select voices, so do not use add_notes for beats.',
|
|
201
|
+
CHORD_PROCESS,
|
|
202
|
+
"Synth voices: set_synth shapes a synth track with Strudel params (ADSR, lpf/lpenv filter envelopes, fm/fmh, supersaw unison/detune, vib, penv, noise; z_* ZzFX sounds take slide/pitchJump/lfo/zcrush…) or a preset (pad lead pluck bass keys bell…).",
|
|
203
|
+
"Sampler voices: set_sample sets Strudel sample controls per voice (begin end speed unit loop loopBegin loopEnd clip/legato fit loopAt accelerate squiz cut); splice = slices with fit.",
|
|
204
|
+
"Effects (set_fx; set_effects and set_automation): the brief's effects list is the fixed chain order with each effect's simple params. set_fx turns an effect on with good defaults, loads a preset or sets params by dawg or Strudel name. Lanes: volume, pan, filter, resonance, delay-feedback, delay-mix, wt and <effect>-<param> (e.g. autofilter-cutoff). Sidechain: put pads/bass on `orbit` 2 and give the kick `duck` (orbit 2, preset pump); a ducker never ducks itself.",
|
|
205
|
+
"Previewing: preview_sound renders a track, or candidate sound tool calls (changes: [{tool, args}]), without committing, returns loudness, brightness and a comparison, and plays it once in the user's window. Use it when choosing between sounds (tables, presets, effect amounts), say how it sounds, then commit with the normal tools.",
|
|
206
|
+
"Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
|
|
193
207
|
"If a call is rejected, read the diagnostic and either fix the arguments or stop.",
|
|
194
208
|
WORKSPACE_PROMPT,
|
|
195
209
|
MEDIA_PROMPT,
|
|
@@ -551,6 +565,18 @@ export async function executeCall(
|
|
|
551
565
|
event: appliedEvent(plan.summary, snapshot.revision),
|
|
552
566
|
};
|
|
553
567
|
}
|
|
568
|
+
if (plan.kind === "prepare") {
|
|
569
|
+
try {
|
|
570
|
+
plan = await plan.run({
|
|
571
|
+
...(context.host.packs ? { packs: context.host.packs } : {}),
|
|
572
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
573
|
+
});
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (context.signal?.aborted) throw context.signal.reason;
|
|
576
|
+
if (isActionDiagnostic(error)) return reject(errorMessage(error));
|
|
577
|
+
return reject(`${call.name} failed: ${errorMessage(error)}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
554
580
|
if (plan.kind === "action") {
|
|
555
581
|
const action: ActionContext = {
|
|
556
582
|
...(context.host.workspace ? { workspace: context.host.workspace } : {}),
|
|
@@ -561,6 +587,8 @@ export async function executeCall(
|
|
|
561
587
|
}
|
|
562
588
|
: {}),
|
|
563
589
|
...(context.host.web ? { web: context.host.web } : {}),
|
|
590
|
+
...(context.host.packs ? { packs: context.host.packs } : {}),
|
|
591
|
+
...(context.host.preview ? { preview: context.host.preview } : {}),
|
|
564
592
|
...(context.signal ? { signal: context.signal } : {}),
|
|
565
593
|
};
|
|
566
594
|
try {
|
|
@@ -642,6 +670,13 @@ export async function executeCall(
|
|
|
642
670
|
next = applyScoreOperation(next, operation);
|
|
643
671
|
operations.push(operation);
|
|
644
672
|
}
|
|
673
|
+
// Keep rhythm rows and their lanes consistent (regenerate after a loop
|
|
674
|
+
// resize, freeze a row whose lane the tool edited by hand).
|
|
675
|
+
const reconciled = reconcileRhythm(snapshot.score, next);
|
|
676
|
+
if (reconciled !== next) {
|
|
677
|
+
operations.push(...diffScores(next, reconciled));
|
|
678
|
+
next = reconciled;
|
|
679
|
+
}
|
|
645
680
|
} catch (error) {
|
|
646
681
|
return reject(`rejected by score validation: ${errorMessage(error)}`);
|
|
647
682
|
}
|
package/src/agent/brief.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { TrackScore } from "../../core/score.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AVAILABLE_EFFECTS,
|
|
4
|
+
AVAILABLE_FX_PRESETS,
|
|
5
|
+
AVAILABLE_INSTRUMENTS,
|
|
6
|
+
} from "../audio/wav.ts";
|
|
3
7
|
import type { ProjectOutline } from "./workspace.ts";
|
|
4
8
|
|
|
5
9
|
export const MAX_BRIEF_BYTES = 12 * 1024;
|
|
@@ -69,6 +73,17 @@ export function compositionBrief(options: {
|
|
|
69
73
|
? { filterAutomation: track.filterAutomation!.length }
|
|
70
74
|
: {}),
|
|
71
75
|
...(track.reverb ? { reverb: track.reverb } : {}),
|
|
76
|
+
...(track.fx ? { fx: track.fx } : {}),
|
|
77
|
+
...(track.fxAutomation
|
|
78
|
+
? {
|
|
79
|
+
fxAutomation: Object.fromEntries(
|
|
80
|
+
Object.entries(track.fxAutomation).map(([lane, points]) => [
|
|
81
|
+
lane,
|
|
82
|
+
points?.length ?? 0,
|
|
83
|
+
]),
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
72
87
|
...((track.resonanceAutomation?.length ?? 0) > 0
|
|
73
88
|
? { resonanceAutomation: track.resonanceAutomation!.length }
|
|
74
89
|
: {}),
|
|
@@ -78,6 +93,10 @@ export function compositionBrief(options: {
|
|
|
78
93
|
...((track.delayMixAutomation?.length ?? 0) > 0
|
|
79
94
|
? { delayMixAutomation: track.delayMixAutomation!.length }
|
|
80
95
|
: {}),
|
|
96
|
+
...(track.wavetable ? { wavetable: track.wavetable } : {}),
|
|
97
|
+
...((track.wtAutomation?.length ?? 0) > 0
|
|
98
|
+
? { wtAutomation: track.wtAutomation!.length }
|
|
99
|
+
: {}),
|
|
81
100
|
};
|
|
82
101
|
});
|
|
83
102
|
const focusedNotes = score.notes
|
|
@@ -104,6 +123,7 @@ export function compositionBrief(options: {
|
|
|
104
123
|
noteLimit: number,
|
|
105
124
|
trackLimit: number,
|
|
106
125
|
projectLevel: number,
|
|
126
|
+
presets: boolean,
|
|
107
127
|
) => {
|
|
108
128
|
const visible = focusedNotes.slice(0, noteLimit);
|
|
109
129
|
return JSON.stringify({
|
|
@@ -128,6 +148,7 @@ export function compositionBrief(options: {
|
|
|
128
148
|
recentOperations: recent,
|
|
129
149
|
instruments: AVAILABLE_INSTRUMENTS,
|
|
130
150
|
effects: AVAILABLE_EFFECTS,
|
|
151
|
+
...(presets ? { fxPresets: AVAILABLE_FX_PRESETS } : {}),
|
|
131
152
|
...(project && projectLevel > 0 && project.tree.length > 0
|
|
132
153
|
? {
|
|
133
154
|
project: {
|
|
@@ -144,13 +165,17 @@ export function compositionBrief(options: {
|
|
|
144
165
|
let noteLimit = Math.min(MAX_FOCUSED_NOTES, focusedNotes.length);
|
|
145
166
|
let trackLimit = tracks.length;
|
|
146
167
|
let projectLevel = 2;
|
|
147
|
-
|
|
168
|
+
// Preset names are a convenience (`set_fx` lists them on a miss), so
|
|
169
|
+
// the brief carries them only when the score already uses effects.
|
|
170
|
+
let presets = score.tracks.some((track) => track.fx !== undefined);
|
|
171
|
+
let brief = build(noteLimit, trackLimit, projectLevel, presets);
|
|
148
172
|
while (encoder.encode(brief).byteLength > maxBytes) {
|
|
149
|
-
if (
|
|
173
|
+
if (presets) presets = false;
|
|
174
|
+
else if (noteLimit > 0) noteLimit = Math.floor(noteLimit / 2);
|
|
150
175
|
else if (projectLevel > 0) projectLevel -= 1;
|
|
151
176
|
else if (trackLimit > 1) trackLimit = Math.floor(trackLimit / 2);
|
|
152
177
|
else break;
|
|
153
|
-
brief = build(noteLimit, trackLimit, projectLevel);
|
|
178
|
+
brief = build(noteLimit, trackLimit, projectLevel, presets);
|
|
154
179
|
}
|
|
155
180
|
return brief;
|
|
156
181
|
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools for chords: `suggest_progression` (read-only) and
|
|
3
|
+
* `write_chords` (score). Both run core/chords.ts, so the agent gets the
|
|
4
|
+
* same key-mode harmony, voice leading and performance play mode uses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
BASS_MODES,
|
|
9
|
+
CHORD_PATTERNS,
|
|
10
|
+
findChordPattern,
|
|
11
|
+
type BassMode,
|
|
12
|
+
generateProgression,
|
|
13
|
+
keyName,
|
|
14
|
+
parseKey,
|
|
15
|
+
PERFORM_MODES,
|
|
16
|
+
PROGRESSION_PRESETS,
|
|
17
|
+
PROGRESSION_STYLES,
|
|
18
|
+
renderProgression,
|
|
19
|
+
resolveChord,
|
|
20
|
+
SPREADS,
|
|
21
|
+
type Chord,
|
|
22
|
+
type Key,
|
|
23
|
+
type PerformMode,
|
|
24
|
+
type Spread,
|
|
25
|
+
} from "../../core/chords.ts";
|
|
26
|
+
import { midiToPitch } from "../../core/pitch.ts";
|
|
27
|
+
import { SCORE_LIMITS, type ScoreOperation } from "../../core/score.ts";
|
|
28
|
+
import type { AgentTool, ToolContext } from "./tools.ts";
|
|
29
|
+
|
|
30
|
+
/** Most notes one write_chords call may add (arpeggios multiply fast). */
|
|
31
|
+
export const MAX_CHORD_NOTES = 512;
|
|
32
|
+
const MAX_CHORDS = 32;
|
|
33
|
+
|
|
34
|
+
class ChordToolError extends Error {
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "ToolArgumentError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STYLE_NAMES = [
|
|
42
|
+
...PROGRESSION_STYLES,
|
|
43
|
+
...PROGRESSION_PRESETS.map((preset) => preset.name),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const str = { type: "string" } as const;
|
|
47
|
+
const int = { type: "integer" } as const;
|
|
48
|
+
const num = { type: "number" } as const;
|
|
49
|
+
const bool = { type: "boolean" } as const;
|
|
50
|
+
const keySchema = {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: '"C major", "a dorian"; default song key',
|
|
53
|
+
} as const;
|
|
54
|
+
const chordsSchema = {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: str,
|
|
57
|
+
description: '"ii7", "bVII", "V/V" or "Cm7", "F/A"',
|
|
58
|
+
} as const;
|
|
59
|
+
const voicingSchema = {
|
|
60
|
+
inversion: { ...int, description: "-12..12" },
|
|
61
|
+
spread: { type: "string", enum: [...SPREADS] },
|
|
62
|
+
} as const;
|
|
63
|
+
const progressionSchema = {
|
|
64
|
+
key: keySchema,
|
|
65
|
+
chords: {
|
|
66
|
+
...chordsSchema,
|
|
67
|
+
description: `${chordsSchema.description}; omit to generate`,
|
|
68
|
+
},
|
|
69
|
+
length: int,
|
|
70
|
+
style: str,
|
|
71
|
+
seed: int,
|
|
72
|
+
sevenths: bool,
|
|
73
|
+
...voicingSchema,
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
type Progression = Readonly<{ key: Key; chords: Chord[] }>;
|
|
77
|
+
|
|
78
|
+
function progressionOf(
|
|
79
|
+
args: Record<string, unknown>,
|
|
80
|
+
context: ToolContext,
|
|
81
|
+
): Progression {
|
|
82
|
+
const keyText =
|
|
83
|
+
args.key === undefined ? (context.score.key ?? "C major") : args.key;
|
|
84
|
+
const key = parseKey(typeof keyText === "string" ? keyText : undefined);
|
|
85
|
+
if (!key)
|
|
86
|
+
throw new ChordToolError(
|
|
87
|
+
`key must look like "C major", "a minor" or "F# dorian"`,
|
|
88
|
+
);
|
|
89
|
+
if (args.chords !== undefined) {
|
|
90
|
+
if (
|
|
91
|
+
!Array.isArray(args.chords) ||
|
|
92
|
+
args.chords.length === 0 ||
|
|
93
|
+
args.chords.length > MAX_CHORDS
|
|
94
|
+
)
|
|
95
|
+
throw new ChordToolError(`chords must list 1..${MAX_CHORDS} chords`);
|
|
96
|
+
const chords = args.chords.map((value, index) => {
|
|
97
|
+
const chord =
|
|
98
|
+
typeof value === "string" ? resolveChord(key, value) : undefined;
|
|
99
|
+
if (!chord)
|
|
100
|
+
throw new ChordToolError(
|
|
101
|
+
`chords[${index}]: not a roman numeral or chord symbol`,
|
|
102
|
+
);
|
|
103
|
+
return chord;
|
|
104
|
+
});
|
|
105
|
+
return { key, chords };
|
|
106
|
+
}
|
|
107
|
+
const length = integer(args, "length", 1, MAX_CHORDS) ?? 4;
|
|
108
|
+
const style = args.style;
|
|
109
|
+
if (
|
|
110
|
+
style !== undefined &&
|
|
111
|
+
(typeof style !== "string" || !STYLE_NAMES.includes(style))
|
|
112
|
+
)
|
|
113
|
+
throw new ChordToolError(`style must be one of ${STYLE_NAMES.join(", ")}`);
|
|
114
|
+
const sevenths = optionalBoolean(args, "sevenths");
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
chords: generateProgression({
|
|
118
|
+
key,
|
|
119
|
+
length,
|
|
120
|
+
...(style !== undefined ? { style } : {}),
|
|
121
|
+
seed: integer(args, "seed", 0, 2 ** 31) ?? 1,
|
|
122
|
+
...(sevenths !== undefined ? { sevenths } : {}),
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function voicingOf(args: Record<string, unknown>): {
|
|
128
|
+
inversion?: number;
|
|
129
|
+
spread?: Spread;
|
|
130
|
+
} {
|
|
131
|
+
const inversion = integer(args, "inversion", -12, 12);
|
|
132
|
+
const spread = args.spread;
|
|
133
|
+
if (spread !== undefined && !(SPREADS as readonly unknown[]).includes(spread))
|
|
134
|
+
throw new ChordToolError(`spread must be one of ${SPREADS.join(", ")}`);
|
|
135
|
+
return {
|
|
136
|
+
...(inversion !== undefined ? { inversion } : {}),
|
|
137
|
+
...(spread !== undefined ? { spread: spread as Spread } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
142
|
+
{
|
|
143
|
+
name: "suggest_progression",
|
|
144
|
+
description: `Read-only: voice-led diatonic chords (name, numeral, voicing, bass). style ${PROGRESSION_STYLES.join("|")} walks a seeded harmony graph; or a preset ${PROGRESSION_PRESETS.map((p) => p.name).join("|")}. length ≤${MAX_CHORDS}.`,
|
|
145
|
+
parameters: {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: progressionSchema,
|
|
148
|
+
additionalProperties: false,
|
|
149
|
+
},
|
|
150
|
+
plan(args, context) {
|
|
151
|
+
const { key, chords } = progressionOf(args, context);
|
|
152
|
+
const rendered = renderProgression({
|
|
153
|
+
key,
|
|
154
|
+
chords,
|
|
155
|
+
...voicingOf(args),
|
|
156
|
+
bass: true,
|
|
157
|
+
});
|
|
158
|
+
const result = {
|
|
159
|
+
key: keyName(key),
|
|
160
|
+
chords: rendered.voiced.map((chord) => ({
|
|
161
|
+
name: chord.name,
|
|
162
|
+
roman: chord.roman,
|
|
163
|
+
voicing: chord.pitches.map((pitch) => midiToPitch(pitch)),
|
|
164
|
+
...(chord.bass !== undefined
|
|
165
|
+
? { bass: midiToPitch(chord.bass) }
|
|
166
|
+
: {}),
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
const names = rendered.voiced.map((chord) => chord.name).join(" ");
|
|
170
|
+
return {
|
|
171
|
+
kind: "action",
|
|
172
|
+
summary: `${keyName(key)}: ${names}`.slice(0, 160),
|
|
173
|
+
run: async () => ({
|
|
174
|
+
content: JSON.stringify(result),
|
|
175
|
+
summary: `${keyName(key)}: ${names}`.slice(0, 160),
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "write_chords",
|
|
182
|
+
description: `Write voice-led chords to a track (one bar each by default), with optional bass here or on bassTrackId. perform pattern uses pattern ${CHORD_PATTERNS.map((p) => p.name).join("|")}. bassMode ${BASS_MODES.join("|")} (solo: bass only). ≤${MAX_CHORD_NOTES} notes.`,
|
|
183
|
+
parameters: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
trackId: str,
|
|
187
|
+
key: keySchema,
|
|
188
|
+
chords: chordsSchema,
|
|
189
|
+
...voicingSchema,
|
|
190
|
+
start: num,
|
|
191
|
+
beatsPerChord: num,
|
|
192
|
+
perform: { type: "string", enum: [...PERFORM_MODES] },
|
|
193
|
+
rate: { ...num, description: "arp step, beats" },
|
|
194
|
+
octaves: int,
|
|
195
|
+
strum: num,
|
|
196
|
+
velocity: num,
|
|
197
|
+
pattern: { type: "string", enum: CHORD_PATTERNS.map((p) => p.name) },
|
|
198
|
+
bass: bool,
|
|
199
|
+
bassMode: { type: "string", enum: [...BASS_MODES] },
|
|
200
|
+
bassTrackId: str,
|
|
201
|
+
},
|
|
202
|
+
required: ["chords"],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
},
|
|
205
|
+
plan(args, context) {
|
|
206
|
+
const trackId = trackOf(args.trackId, context, "trackId");
|
|
207
|
+
if (args.chords === undefined)
|
|
208
|
+
throw new ChordToolError(
|
|
209
|
+
"write_chords needs chords (try suggest_progression)",
|
|
210
|
+
);
|
|
211
|
+
const bassTrackId =
|
|
212
|
+
args.bassTrackId === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: trackOf(args.bassTrackId, context, "bassTrackId");
|
|
215
|
+
const { key, chords } = progressionOf(args, context);
|
|
216
|
+
const mode = args.perform;
|
|
217
|
+
if (
|
|
218
|
+
mode !== undefined &&
|
|
219
|
+
!(PERFORM_MODES as readonly unknown[]).includes(mode)
|
|
220
|
+
)
|
|
221
|
+
throw new ChordToolError(
|
|
222
|
+
`perform must be one of ${PERFORM_MODES.join(", ")}`,
|
|
223
|
+
);
|
|
224
|
+
const velocity = finite(args, "velocity", 0, 1);
|
|
225
|
+
const rate = finite(args, "rate", 0.0625, 4);
|
|
226
|
+
const strum = finite(args, "strum", 0, 1);
|
|
227
|
+
const octaves = integer(args, "octaves", 1, 4);
|
|
228
|
+
const pattern = args.pattern;
|
|
229
|
+
if (
|
|
230
|
+
pattern !== undefined &&
|
|
231
|
+
(typeof pattern !== "string" || !findChordPattern(pattern))
|
|
232
|
+
)
|
|
233
|
+
throw new ChordToolError(
|
|
234
|
+
`pattern must be one of ${CHORD_PATTERNS.map((p) => p.name).join(", ")}`,
|
|
235
|
+
);
|
|
236
|
+
const bassMode = args.bassMode;
|
|
237
|
+
if (
|
|
238
|
+
bassMode !== undefined &&
|
|
239
|
+
!(BASS_MODES as readonly unknown[]).includes(bassMode)
|
|
240
|
+
)
|
|
241
|
+
throw new ChordToolError(
|
|
242
|
+
`bassMode must be one of ${BASS_MODES.join(", ")}`,
|
|
243
|
+
);
|
|
244
|
+
const rendered = renderProgression({
|
|
245
|
+
key,
|
|
246
|
+
chords,
|
|
247
|
+
start: finite(args, "start", 0, Infinity) ?? 0,
|
|
248
|
+
beatsPerChord:
|
|
249
|
+
finite(args, "beatsPerChord", 1e-3, 64) ?? context.score.beatsPerBar,
|
|
250
|
+
...voicingOf(args),
|
|
251
|
+
bass: args.bass === true || bassTrackId !== undefined,
|
|
252
|
+
...(bassMode !== undefined ? { bassMode: bassMode as BassMode } : {}),
|
|
253
|
+
perform: {
|
|
254
|
+
mode: (mode as PerformMode | undefined) ?? "block",
|
|
255
|
+
...(rate !== undefined ? { rate } : {}),
|
|
256
|
+
...(strum !== undefined ? { strum } : {}),
|
|
257
|
+
...(octaves !== undefined ? { octaves } : {}),
|
|
258
|
+
...(velocity !== undefined ? { velocity } : {}),
|
|
259
|
+
seed: integer(args, "seed", 0, 2 ** 31) ?? 0,
|
|
260
|
+
...(typeof pattern === "string" ? { pattern } : {}),
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
const total = rendered.notes.length + rendered.bass.length;
|
|
264
|
+
if (total > MAX_CHORD_NOTES)
|
|
265
|
+
throw new ChordToolError(
|
|
266
|
+
`${total} notes exceed ${MAX_CHORD_NOTES}; use fewer chords, a slower rate or fewer octaves`,
|
|
267
|
+
);
|
|
268
|
+
const tpb = context.score.ticksPerBeat;
|
|
269
|
+
const operations: ScoreOperation[] = [];
|
|
270
|
+
const add = (
|
|
271
|
+
notes: readonly {
|
|
272
|
+
pitch: number;
|
|
273
|
+
start: number;
|
|
274
|
+
length: number;
|
|
275
|
+
velocity: number;
|
|
276
|
+
}[],
|
|
277
|
+
target: string,
|
|
278
|
+
) => {
|
|
279
|
+
for (const note of notes)
|
|
280
|
+
operations.push({
|
|
281
|
+
type: "addNote",
|
|
282
|
+
note: {
|
|
283
|
+
id: context.newNoteId(target, operations.length),
|
|
284
|
+
trackId: target,
|
|
285
|
+
startTick: Math.round(note.start * tpb),
|
|
286
|
+
durationTicks: Math.max(1, Math.round(note.length * tpb)),
|
|
287
|
+
pitch: note.pitch,
|
|
288
|
+
velocity: note.velocity,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
add(rendered.notes, trackId);
|
|
293
|
+
add(rendered.bass, bassTrackId ?? trackId);
|
|
294
|
+
const names = rendered.voiced.map((chord) => chord.name).join(" ");
|
|
295
|
+
return {
|
|
296
|
+
kind: "score",
|
|
297
|
+
operations,
|
|
298
|
+
trackId,
|
|
299
|
+
summary: `${trackId} chords ${names}`.slice(0, 160),
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
function trackOf(value: unknown, context: ToolContext, label: string): string {
|
|
306
|
+
const trackId = value ?? context.focusedTrackId;
|
|
307
|
+
if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
|
|
308
|
+
throw new ChordToolError(`${label} must be a short string`);
|
|
309
|
+
if (!context.score.tracks.some((track) => track.id === trackId))
|
|
310
|
+
throw new ChordToolError(
|
|
311
|
+
`unknown track ${trackId}; create it with create_track first`,
|
|
312
|
+
);
|
|
313
|
+
return trackId;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function finite(
|
|
317
|
+
args: Record<string, unknown>,
|
|
318
|
+
name: string,
|
|
319
|
+
min: number,
|
|
320
|
+
max: number,
|
|
321
|
+
): number | undefined {
|
|
322
|
+
const value = args[name];
|
|
323
|
+
if (value === undefined) return undefined;
|
|
324
|
+
if (
|
|
325
|
+
typeof value !== "number" ||
|
|
326
|
+
!Number.isFinite(value) ||
|
|
327
|
+
value < min ||
|
|
328
|
+
value > max
|
|
329
|
+
)
|
|
330
|
+
throw new ChordToolError(
|
|
331
|
+
`${name} must be a number in ${min}..${max === Infinity ? "∞" : max}`,
|
|
332
|
+
);
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function integer(
|
|
337
|
+
args: Record<string, unknown>,
|
|
338
|
+
name: string,
|
|
339
|
+
min: number,
|
|
340
|
+
max: number,
|
|
341
|
+
): number | undefined {
|
|
342
|
+
const value = finite(args, name, min, max);
|
|
343
|
+
if (value !== undefined && !Number.isInteger(value))
|
|
344
|
+
throw new ChordToolError(`${name} must be an integer`);
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function optionalBoolean(
|
|
349
|
+
args: Record<string, unknown>,
|
|
350
|
+
name: string,
|
|
351
|
+
): boolean | undefined {
|
|
352
|
+
const value = args[name];
|
|
353
|
+
if (value === undefined) return undefined;
|
|
354
|
+
if (typeof value !== "boolean")
|
|
355
|
+
throw new ChordToolError(`${name} must be true or false`);
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools for the drum pattern library and the synthesized kits:
|
|
3
|
+
* `list_drum_patterns`, `apply_drum_pattern`, `set_drum_kit`. Patterns land
|
|
4
|
+
* as rhythm rows (`set_rhythm` edits them afterwards), so a groove stays a
|
|
5
|
+
* few parameters per voice instead of dozens of notes.
|
|
6
|
+
*/
|
|
7
|
+
import { diffScores } from "../../core/diff.ts";
|
|
8
|
+
import { SYNTH_KITS } from "../../core/kits.ts";
|
|
9
|
+
import { SCORE_LIMITS } from "../../core/score.ts";
|
|
10
|
+
import {
|
|
11
|
+
applyDrumPattern,
|
|
12
|
+
applySynthKit,
|
|
13
|
+
DRUM_PATTERNS,
|
|
14
|
+
patternLine,
|
|
15
|
+
} from "../commands/drums.ts";
|
|
16
|
+
import type { AgentTool, ToolContext } from "./tools.ts";
|
|
17
|
+
import { ToolArgumentError } from "./tools.ts";
|
|
18
|
+
|
|
19
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
20
|
+
|
|
21
|
+
function trackIdOf(args: Record<string, unknown>, context: ToolContext) {
|
|
22
|
+
const trackId = args.trackId ?? context.focusedTrackId;
|
|
23
|
+
if (typeof trackId !== "string" || !ID_PATTERN.test(trackId))
|
|
24
|
+
throw new ToolArgumentError(
|
|
25
|
+
"trackId must be 1-64 letters, digits, dot, dash, or underscore",
|
|
26
|
+
);
|
|
27
|
+
return trackId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trackId = {
|
|
31
|
+
type: "string",
|
|
32
|
+
maxLength: SCORE_LIMITS.maxIdLength,
|
|
33
|
+
description:
|
|
34
|
+
"Drum track id (created when missing). Defaults to the focused track.",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export const DRUM_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
38
|
+
{
|
|
39
|
+
name: "list_drum_patterns",
|
|
40
|
+
description:
|
|
41
|
+
"List the built-in drum patterns (name · tempo range · genre tags · voices). Filter by a word such as house, trap or 2-step.",
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: { filter: { type: "string", maxLength: 40 } },
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
plan(args) {
|
|
48
|
+
const needle =
|
|
49
|
+
typeof args.filter === "string" ? args.filter.trim().toLowerCase() : "";
|
|
50
|
+
const lines = DRUM_PATTERNS.filter(
|
|
51
|
+
(entry) =>
|
|
52
|
+
!needle ||
|
|
53
|
+
entry.name.includes(needle) ||
|
|
54
|
+
entry.tags.some((tag) => tag.includes(needle)),
|
|
55
|
+
).map(patternLine);
|
|
56
|
+
return {
|
|
57
|
+
kind: "action",
|
|
58
|
+
summary: needle ? `patterns ${needle}` : "list patterns",
|
|
59
|
+
run: async () => ({
|
|
60
|
+
content: JSON.stringify({
|
|
61
|
+
ok: true,
|
|
62
|
+
patterns: lines,
|
|
63
|
+
hint: "apply_drum_pattern puts one on a drum track; set_rhythm then edits its rows",
|
|
64
|
+
}),
|
|
65
|
+
summary: `${lines.length} patterns`,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "apply_drum_pattern",
|
|
72
|
+
description:
|
|
73
|
+
"Replace a drum track's notes with a library pattern as Euclidean/grid rhythm rows (one per voice). Moves the tempo into the pattern's range unless tempo is keep.",
|
|
74
|
+
parameters: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
name: { type: "string", maxLength: 40 },
|
|
78
|
+
trackId,
|
|
79
|
+
tempo: { type: "string", enum: ["auto", "keep", "set"] },
|
|
80
|
+
},
|
|
81
|
+
required: ["name"],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
plan(args, context) {
|
|
85
|
+
if (typeof args.name !== "string" || !args.name.trim())
|
|
86
|
+
throw new ToolArgumentError("name must be a pattern name");
|
|
87
|
+
const id = trackIdOf(args, context);
|
|
88
|
+
const tempo =
|
|
89
|
+
args.tempo === "keep" || args.tempo === "set" ? args.tempo : "auto";
|
|
90
|
+
const result = applyDrumPattern(
|
|
91
|
+
context.score,
|
|
92
|
+
id,
|
|
93
|
+
args.name.trim().toLowerCase(),
|
|
94
|
+
tempo,
|
|
95
|
+
);
|
|
96
|
+
if (!result.ok || !result.next)
|
|
97
|
+
throw new ToolArgumentError(result.message);
|
|
98
|
+
return {
|
|
99
|
+
kind: "score",
|
|
100
|
+
operations: diffScores(context.score, result.next),
|
|
101
|
+
trackId: id,
|
|
102
|
+
summary: result.message,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "set_drum_kit",
|
|
108
|
+
description: `Choose the synthesized drum kit of a drum track: ${SYNTH_KITS.map((kit) => kit.name).join(", ")}, or default. Offline and instant; sample kits (909, 808, linn, …) go through use_sound.`,
|
|
109
|
+
parameters: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
kit: {
|
|
113
|
+
type: "string",
|
|
114
|
+
enum: ["default", ...SYNTH_KITS.map((kit) => kit.name)],
|
|
115
|
+
},
|
|
116
|
+
trackId,
|
|
117
|
+
},
|
|
118
|
+
required: ["kit"],
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
},
|
|
121
|
+
plan(args, context) {
|
|
122
|
+
if (typeof args.kit !== "string")
|
|
123
|
+
throw new ToolArgumentError("kit must be a synth kit name");
|
|
124
|
+
const id = trackIdOf(args, context);
|
|
125
|
+
const result = applySynthKit(context.score, id, args.kit);
|
|
126
|
+
if (!result.ok) throw new ToolArgumentError(result.message);
|
|
127
|
+
return {
|
|
128
|
+
kind: "score",
|
|
129
|
+
operations: result.next ? diffScores(context.score, result.next) : [],
|
|
130
|
+
trackId: id,
|
|
131
|
+
summary: result.message,
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
]);
|