@hraness/dawg 0.0.0-stage → 0.3.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.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTOMATION_LANES,
|
|
3
|
+
SCORE_LIMITS,
|
|
4
|
+
type AutomationParameter,
|
|
5
|
+
type ScoreOperation,
|
|
6
|
+
type TrackScore,
|
|
7
|
+
} from "../../core/score.ts";
|
|
8
|
+
import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
9
|
+
import type { ChatTool } from "./gateway.ts";
|
|
10
|
+
import { MEDIA_TOOLS } from "../media/tools.ts";
|
|
11
|
+
import type { MediaResult, MediaRunContext } from "../media/types.ts";
|
|
12
|
+
import { pitchToMidi } from "./ops.ts";
|
|
13
|
+
import {
|
|
14
|
+
DRUM_VOICES,
|
|
15
|
+
drumVoicePitch,
|
|
16
|
+
isDrumInstrument,
|
|
17
|
+
parseDrumVoice,
|
|
18
|
+
type DrumVoice,
|
|
19
|
+
} from "../../core/drums.ts";
|
|
20
|
+
import { trackSlug } from "../../core/slug.ts";
|
|
21
|
+
import {
|
|
22
|
+
editFile,
|
|
23
|
+
listFiles,
|
|
24
|
+
readFile,
|
|
25
|
+
WORKSPACE_LIMITS,
|
|
26
|
+
WorkspaceError,
|
|
27
|
+
writeFile,
|
|
28
|
+
type WorkspaceScope,
|
|
29
|
+
} from "./workspace.ts";
|
|
30
|
+
import {
|
|
31
|
+
fetchUrl,
|
|
32
|
+
FETCH_LIMITS,
|
|
33
|
+
formatFetchedPage,
|
|
34
|
+
type Lookup,
|
|
35
|
+
} from "../web/fetch.ts";
|
|
36
|
+
import { WebError, type FetchLike } from "../web/http.ts";
|
|
37
|
+
import {
|
|
38
|
+
describeSearchProvider,
|
|
39
|
+
formatSearchResults,
|
|
40
|
+
SEARCH_LIMITS,
|
|
41
|
+
webSearch,
|
|
42
|
+
type SearchSpend,
|
|
43
|
+
} from "../web/search.ts";
|
|
44
|
+
|
|
45
|
+
/** What a validated tool call asks the host to do. */
|
|
46
|
+
export type ToolPlan =
|
|
47
|
+
| Readonly<{
|
|
48
|
+
kind: "score";
|
|
49
|
+
operations: readonly ScoreOperation[];
|
|
50
|
+
summary: string;
|
|
51
|
+
trackId?: string;
|
|
52
|
+
}>
|
|
53
|
+
| Readonly<{
|
|
54
|
+
kind: "transport";
|
|
55
|
+
action: "play" | "pause" | "toggle";
|
|
56
|
+
summary: string;
|
|
57
|
+
}>
|
|
58
|
+
| Readonly<{ kind: "explain"; text: string; summary: string }>
|
|
59
|
+
| Readonly<{
|
|
60
|
+
kind: "action";
|
|
61
|
+
summary: string;
|
|
62
|
+
/** Side effects outside the score (files, network); bounded and async. */
|
|
63
|
+
run: (context: ActionContext) => Promise<ActionResult>;
|
|
64
|
+
}>
|
|
65
|
+
/** A long-running local media job (download, stems, analysis, …). */
|
|
66
|
+
| Readonly<{
|
|
67
|
+
kind: "media";
|
|
68
|
+
summary: string;
|
|
69
|
+
run: (context: MediaRunContext) => Promise<MediaResult>;
|
|
70
|
+
}>;
|
|
71
|
+
|
|
72
|
+
/** The project directory the workspace tools operate in. */
|
|
73
|
+
export type WorkspaceHost = Readonly<{ root: string }>;
|
|
74
|
+
|
|
75
|
+
/** Injection points for the web tools; defaults are the real network. */
|
|
76
|
+
export type WebHost = Readonly<{
|
|
77
|
+
fetch?: FetchLike;
|
|
78
|
+
lookup?: Lookup;
|
|
79
|
+
/** Brave Web Search API key; defaults to `BRAVE_SEARCH_API_KEY` and overrides the chain. */
|
|
80
|
+
braveApiKey?: string;
|
|
81
|
+
/** AI Gateway key of the active provider; enables gateway search tools. */
|
|
82
|
+
gatewayApiKey?: string;
|
|
83
|
+
gatewayBaseUrl?: string;
|
|
84
|
+
/** Gateway search tool; defaults to `DAWG_WEB_SEARCH`, then `exa`. */
|
|
85
|
+
searchTool?: string;
|
|
86
|
+
/** OpenRouter key; defaults to `OPENROUTER_API_KEY`. */
|
|
87
|
+
openRouterApiKey?: string;
|
|
88
|
+
openRouterBaseUrl?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Billed searches (gateway tools, OpenRouter web plugin) are reported here
|
|
91
|
+
* so the host can add them to its spend ledger (`webHostFor` in
|
|
92
|
+
* `src/agent/usage.ts`).
|
|
93
|
+
*/
|
|
94
|
+
onSpend?: (spend: SearchSpend) => void;
|
|
95
|
+
}>;
|
|
96
|
+
|
|
97
|
+
/** What an `action` plan receives from the host when it runs. */
|
|
98
|
+
export type ActionContext = Readonly<{
|
|
99
|
+
workspace?: WorkspaceHost;
|
|
100
|
+
/**
|
|
101
|
+
* Called after a successful `write_file`/`edit_file` with the
|
|
102
|
+
* project-relative path. Returned text (for example typecheck
|
|
103
|
+
* diagnostics) is appended to the tool result.
|
|
104
|
+
*/
|
|
105
|
+
onWorkspaceWrite?: (path: string) => Promise<string | void> | string | void;
|
|
106
|
+
web?: WebHost;
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
}>;
|
|
109
|
+
|
|
110
|
+
export type ActionResult = Readonly<{
|
|
111
|
+
/** What the model reads; already bounded by the tool. */
|
|
112
|
+
content: string;
|
|
113
|
+
/** One line for the activity card. */
|
|
114
|
+
summary: string;
|
|
115
|
+
/** True when something outside the score changed (a file write). */
|
|
116
|
+
mutated?: boolean;
|
|
117
|
+
}>;
|
|
118
|
+
|
|
119
|
+
export type ToolContext = Readonly<{
|
|
120
|
+
score: TrackScore;
|
|
121
|
+
focusedTrackId: string;
|
|
122
|
+
revision: number;
|
|
123
|
+
/** Deterministic per-call note ID factory. */
|
|
124
|
+
newNoteId: (trackId: string, index: number) => string;
|
|
125
|
+
}>;
|
|
126
|
+
|
|
127
|
+
export type AgentTool = Readonly<{
|
|
128
|
+
name: string;
|
|
129
|
+
description: string;
|
|
130
|
+
parameters: Record<string, unknown>;
|
|
131
|
+
plan: (args: Record<string, unknown>, context: ToolContext) => ToolPlan;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
/** A model-supplied argument that the tool refused; never mutates the score. */
|
|
135
|
+
export class ToolArgumentError extends Error {
|
|
136
|
+
constructor(message: string) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = "ToolArgumentError";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const AUTOMATION_PARAMETERS = Object.keys(
|
|
143
|
+
AUTOMATION_LANES,
|
|
144
|
+
) as AutomationParameter[];
|
|
145
|
+
|
|
146
|
+
const MAX_NOTES_PER_CALL = 128;
|
|
147
|
+
const MAX_EXPLAIN_CHARS = 2_000;
|
|
148
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
149
|
+
|
|
150
|
+
const trackIdSchema = {
|
|
151
|
+
type: "string",
|
|
152
|
+
description: "Target track id. Defaults to the focused track.",
|
|
153
|
+
maxLength: SCORE_LIMITS.maxIdLength,
|
|
154
|
+
};
|
|
155
|
+
const beatSchema = (description: string) => ({
|
|
156
|
+
type: "number",
|
|
157
|
+
minimum: 0,
|
|
158
|
+
description,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One tool per score operation family. Add a new family by appending an entry
|
|
163
|
+
* here; the agent loop, schemas, and validation pick it up automatically.
|
|
164
|
+
*/
|
|
165
|
+
export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
166
|
+
{
|
|
167
|
+
name: "add_notes",
|
|
168
|
+
description:
|
|
169
|
+
"Add notes to one track. Times are in beats from the loop start; pitch is MIDI 0..127 or a name like C4/F#2.",
|
|
170
|
+
parameters: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
trackId: trackIdSchema,
|
|
174
|
+
notes: {
|
|
175
|
+
type: "array",
|
|
176
|
+
minItems: 1,
|
|
177
|
+
maxItems: MAX_NOTES_PER_CALL,
|
|
178
|
+
items: {
|
|
179
|
+
type: "object",
|
|
180
|
+
properties: {
|
|
181
|
+
pitch: {
|
|
182
|
+
anyOf: [
|
|
183
|
+
{ type: "integer", minimum: 0, maximum: 127 },
|
|
184
|
+
{ type: "string", maxLength: 4 },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
start: beatSchema("Start in beats"),
|
|
188
|
+
duration: {
|
|
189
|
+
type: "number",
|
|
190
|
+
exclusiveMinimum: 0,
|
|
191
|
+
description: "Length in beats",
|
|
192
|
+
},
|
|
193
|
+
velocity: { type: "number", minimum: 0, maximum: 1 },
|
|
194
|
+
},
|
|
195
|
+
required: ["pitch", "start", "duration"],
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: ["notes"],
|
|
201
|
+
additionalProperties: false,
|
|
202
|
+
},
|
|
203
|
+
plan(args, context) {
|
|
204
|
+
const trackId = targetTrack(args, context);
|
|
205
|
+
const notes = list(args, "notes", 1, MAX_NOTES_PER_CALL);
|
|
206
|
+
const tpb = context.score.ticksPerBeat;
|
|
207
|
+
const operations = notes.map((value, index): ScoreOperation => {
|
|
208
|
+
const note = record(value, `notes[${index}]`);
|
|
209
|
+
const start = number(note, "start", { min: 0 });
|
|
210
|
+
const duration = number(note, "duration", { min: 0, exclusive: true });
|
|
211
|
+
const velocity = optionalNumber(note, "velocity", { min: 0, max: 1 });
|
|
212
|
+
return {
|
|
213
|
+
type: "addNote",
|
|
214
|
+
note: {
|
|
215
|
+
id: context.newNoteId(trackId, index),
|
|
216
|
+
trackId,
|
|
217
|
+
startTick: Math.round(start * tpb),
|
|
218
|
+
durationTicks: Math.max(1, Math.round(duration * tpb)),
|
|
219
|
+
pitch: pitch(note.pitch, `notes[${index}].pitch`),
|
|
220
|
+
velocity: velocity ?? 0.8,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
kind: "score",
|
|
226
|
+
operations,
|
|
227
|
+
trackId,
|
|
228
|
+
summary: `+${operations.length} ${trackId} note${operations.length === 1 ? "" : "s"}`,
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "remove_notes",
|
|
234
|
+
description:
|
|
235
|
+
"Remove notes by id, or every note on a track with all=true. Note ids come from the composition brief.",
|
|
236
|
+
parameters: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
trackId: trackIdSchema,
|
|
240
|
+
noteIds: {
|
|
241
|
+
type: "array",
|
|
242
|
+
maxItems: MAX_NOTES_PER_CALL,
|
|
243
|
+
items: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
|
|
244
|
+
},
|
|
245
|
+
all: { type: "boolean" },
|
|
246
|
+
},
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
},
|
|
249
|
+
plan(args, context) {
|
|
250
|
+
if (args.all === true) {
|
|
251
|
+
const trackId = targetTrack(args, context);
|
|
252
|
+
return {
|
|
253
|
+
kind: "score",
|
|
254
|
+
operations: [{ type: "clearTrack", trackId }],
|
|
255
|
+
trackId,
|
|
256
|
+
summary: `cleared ${trackId}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const ids = list(args, "noteIds", 1, MAX_NOTES_PER_CALL).map((id, i) =>
|
|
260
|
+
knownNoteId(id, context, `noteIds[${i}]`),
|
|
261
|
+
);
|
|
262
|
+
return {
|
|
263
|
+
kind: "score",
|
|
264
|
+
operations: ids.map((noteId) => ({ type: "removeNote", noteId })),
|
|
265
|
+
summary: `−${ids.length} note${ids.length === 1 ? "" : "s"}`,
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "update_notes",
|
|
271
|
+
description:
|
|
272
|
+
"Move, resize, transpose, or re-velocity existing notes by id. Times are in beats.",
|
|
273
|
+
parameters: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
updates: {
|
|
277
|
+
type: "array",
|
|
278
|
+
minItems: 1,
|
|
279
|
+
maxItems: MAX_NOTES_PER_CALL,
|
|
280
|
+
items: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {
|
|
283
|
+
noteId: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
|
|
284
|
+
start: beatSchema("New start in beats"),
|
|
285
|
+
duration: { type: "number", exclusiveMinimum: 0 },
|
|
286
|
+
pitch: {
|
|
287
|
+
anyOf: [
|
|
288
|
+
{ type: "integer", minimum: 0, maximum: 127 },
|
|
289
|
+
{ type: "string", maxLength: 4 },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
velocity: { type: "number", minimum: 0, maximum: 1 },
|
|
293
|
+
},
|
|
294
|
+
required: ["noteId"],
|
|
295
|
+
additionalProperties: false,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
required: ["updates"],
|
|
300
|
+
additionalProperties: false,
|
|
301
|
+
},
|
|
302
|
+
plan(args, context) {
|
|
303
|
+
const tpb = context.score.ticksPerBeat;
|
|
304
|
+
const operations = list(args, "updates", 1, MAX_NOTES_PER_CALL).map(
|
|
305
|
+
(value, index): ScoreOperation => {
|
|
306
|
+
const update = record(value, `updates[${index}]`);
|
|
307
|
+
const noteId = knownNoteId(
|
|
308
|
+
update.noteId,
|
|
309
|
+
context,
|
|
310
|
+
`updates[${index}].noteId`,
|
|
311
|
+
);
|
|
312
|
+
const patch: Record<string, number> = {};
|
|
313
|
+
const start = optionalNumber(update, "start", { min: 0 });
|
|
314
|
+
if (start !== undefined) patch.startTick = Math.round(start * tpb);
|
|
315
|
+
const duration = optionalNumber(update, "duration", {
|
|
316
|
+
min: 0,
|
|
317
|
+
exclusive: true,
|
|
318
|
+
});
|
|
319
|
+
if (duration !== undefined)
|
|
320
|
+
patch.durationTicks = Math.max(1, Math.round(duration * tpb));
|
|
321
|
+
if (update.pitch !== undefined)
|
|
322
|
+
patch.pitch = pitch(update.pitch, `updates[${index}].pitch`);
|
|
323
|
+
const velocity = optionalNumber(update, "velocity", {
|
|
324
|
+
min: 0,
|
|
325
|
+
max: 1,
|
|
326
|
+
});
|
|
327
|
+
if (velocity !== undefined) patch.velocity = velocity;
|
|
328
|
+
if (Object.keys(patch).length === 0)
|
|
329
|
+
throw new ToolArgumentError(`updates[${index}] changes nothing`);
|
|
330
|
+
return { type: "updateNote", noteId, patch };
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
return {
|
|
334
|
+
kind: "score",
|
|
335
|
+
operations,
|
|
336
|
+
summary: `~${operations.length} note${operations.length === 1 ? "" : "s"}`,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "set_instrument",
|
|
342
|
+
description: "Change a track's instrument voice.",
|
|
343
|
+
parameters: {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: {
|
|
346
|
+
trackId: trackIdSchema,
|
|
347
|
+
instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
|
|
348
|
+
},
|
|
349
|
+
required: ["instrument"],
|
|
350
|
+
additionalProperties: false,
|
|
351
|
+
},
|
|
352
|
+
plan(args, context) {
|
|
353
|
+
const trackId = targetTrack(args, context);
|
|
354
|
+
const instrument = instrumentName(args.instrument);
|
|
355
|
+
return {
|
|
356
|
+
kind: "score",
|
|
357
|
+
operations: [{ type: "updateTrack", trackId, patch: { instrument } }],
|
|
358
|
+
trackId,
|
|
359
|
+
summary: `${trackId} → ${instrument}`,
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: "set_mix",
|
|
365
|
+
description:
|
|
366
|
+
"Set a track's static volume (0..1), pan (-1 left .. 1 right), mute, or solo. While any track is soloed only soloed tracks play.",
|
|
367
|
+
parameters: {
|
|
368
|
+
type: "object",
|
|
369
|
+
properties: {
|
|
370
|
+
trackId: trackIdSchema,
|
|
371
|
+
volume: { type: "number", minimum: 0, maximum: 1 },
|
|
372
|
+
pan: { type: "number", minimum: -1, maximum: 1 },
|
|
373
|
+
muted: { type: "boolean" },
|
|
374
|
+
solo: { type: "boolean" },
|
|
375
|
+
},
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
},
|
|
378
|
+
plan(args, context) {
|
|
379
|
+
const trackId = targetTrack(args, context);
|
|
380
|
+
const patch: {
|
|
381
|
+
volume?: number;
|
|
382
|
+
pan?: number;
|
|
383
|
+
muted?: boolean;
|
|
384
|
+
solo?: boolean;
|
|
385
|
+
} = {};
|
|
386
|
+
const volume = optionalNumber(args, "volume", { min: 0, max: 1 });
|
|
387
|
+
if (volume !== undefined) patch.volume = volume;
|
|
388
|
+
const pan = optionalNumber(args, "pan", { min: -1, max: 1 });
|
|
389
|
+
if (pan !== undefined) patch.pan = pan;
|
|
390
|
+
if (args.muted !== undefined) {
|
|
391
|
+
if (typeof args.muted !== "boolean")
|
|
392
|
+
throw new ToolArgumentError("muted must be a boolean");
|
|
393
|
+
patch.muted = args.muted;
|
|
394
|
+
}
|
|
395
|
+
if (args.solo !== undefined) {
|
|
396
|
+
if (typeof args.solo !== "boolean")
|
|
397
|
+
throw new ToolArgumentError("solo must be a boolean");
|
|
398
|
+
patch.solo = args.solo;
|
|
399
|
+
}
|
|
400
|
+
const parts = Object.entries(patch).map(([key, value]) =>
|
|
401
|
+
key === "muted"
|
|
402
|
+
? value
|
|
403
|
+
? "muted"
|
|
404
|
+
: "unmuted"
|
|
405
|
+
: key === "solo"
|
|
406
|
+
? value
|
|
407
|
+
? "solo"
|
|
408
|
+
: "unsolo"
|
|
409
|
+
: `${key} ${value}`,
|
|
410
|
+
);
|
|
411
|
+
if (parts.length === 0)
|
|
412
|
+
throw new ToolArgumentError(
|
|
413
|
+
"set_mix needs volume, pan, muted, or solo",
|
|
414
|
+
);
|
|
415
|
+
return {
|
|
416
|
+
kind: "score",
|
|
417
|
+
operations: [{ type: "updateTrack", trackId, patch }],
|
|
418
|
+
trackId,
|
|
419
|
+
summary: `${trackId} ${parts.join(", ")}`,
|
|
420
|
+
};
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: "set_automation",
|
|
425
|
+
description: `Write an automation lane: volume (0..1), pan (-1..1), filter cutoff (${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz low-pass), resonance (0..${SCORE_LIMITS.maxFilterResonance}), delay-feedback (0..${SCORE_LIMITS.maxDelayFeedback}) or delay-mix (0..${SCORE_LIMITS.maxDelayMix}). Delay lanes need a delay on the track (set_effects) to be heard. mode=replace (default) rewrites the lane; merge keeps existing points at other beats. An empty replace clears it.`,
|
|
426
|
+
parameters: {
|
|
427
|
+
type: "object",
|
|
428
|
+
properties: {
|
|
429
|
+
trackId: trackIdSchema,
|
|
430
|
+
parameter: { type: "string", enum: AUTOMATION_PARAMETERS },
|
|
431
|
+
mode: { type: "string", enum: ["replace", "merge"] },
|
|
432
|
+
points: {
|
|
433
|
+
type: "array",
|
|
434
|
+
maxItems: SCORE_LIMITS.maxAutomationPoints,
|
|
435
|
+
items: {
|
|
436
|
+
type: "object",
|
|
437
|
+
properties: {
|
|
438
|
+
beat: beatSchema("Beat position"),
|
|
439
|
+
value: {
|
|
440
|
+
type: "number",
|
|
441
|
+
description:
|
|
442
|
+
"volume 0..1, pan -1..1, filter cutoff in Hz, resonance 0..1, delay-feedback 0..0.9, delay-mix 0..1",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
required: ["beat", "value"],
|
|
446
|
+
additionalProperties: false,
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
required: ["parameter", "points"],
|
|
451
|
+
additionalProperties: false,
|
|
452
|
+
},
|
|
453
|
+
plan(args, context) {
|
|
454
|
+
const trackId = targetTrack(args, context);
|
|
455
|
+
const parameter = oneOf(
|
|
456
|
+
args.parameter,
|
|
457
|
+
AUTOMATION_PARAMETERS,
|
|
458
|
+
"parameter",
|
|
459
|
+
);
|
|
460
|
+
const mode =
|
|
461
|
+
args.mode === undefined
|
|
462
|
+
? "replace"
|
|
463
|
+
: oneOf(args.mode, ["replace", "merge"], "mode");
|
|
464
|
+
const { min, max } = AUTOMATION_LANES[parameter];
|
|
465
|
+
const tpb = context.score.ticksPerBeat;
|
|
466
|
+
const points = list(
|
|
467
|
+
args,
|
|
468
|
+
"points",
|
|
469
|
+
0,
|
|
470
|
+
SCORE_LIMITS.maxAutomationPoints,
|
|
471
|
+
).map((value, index) => {
|
|
472
|
+
const point = record(value, `points[${index}]`);
|
|
473
|
+
return {
|
|
474
|
+
tick: Math.round(number(point, "beat", { min: 0 }) * tpb),
|
|
475
|
+
value: number(point, "value", { min, max }),
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
const track = context.score.tracks.find((t) => t.id === trackId)!;
|
|
479
|
+
const existing = track[AUTOMATION_LANES[parameter].field] ?? [];
|
|
480
|
+
const merged = Array.from(
|
|
481
|
+
new Map(
|
|
482
|
+
[...(mode === "merge" ? existing : []), ...points].map((point) => [
|
|
483
|
+
point.tick,
|
|
484
|
+
point,
|
|
485
|
+
]),
|
|
486
|
+
).values(),
|
|
487
|
+
).sort((left, right) => left.tick - right.tick);
|
|
488
|
+
return {
|
|
489
|
+
kind: "score",
|
|
490
|
+
operations: [
|
|
491
|
+
{ type: "setAutomation", trackId, parameter, points: merged },
|
|
492
|
+
],
|
|
493
|
+
trackId,
|
|
494
|
+
summary:
|
|
495
|
+
merged.length === 0
|
|
496
|
+
? `${trackId} ${parameter} automation cleared`
|
|
497
|
+
: `${trackId} ${parameter} automation · ${merged.length} point${merged.length === 1 ? "" : "s"}`,
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "set_effects",
|
|
503
|
+
description: `Set or remove a track's low-pass filter, tempo-synced stereo delay, and stereo reverb send. Pass null to remove an effect. filter: cutoff ${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz, resonance 0..${SCORE_LIMITS.maxFilterResonance}. delay: beats ${SCORE_LIMITS.minDelayBeats}..${SCORE_LIMITS.maxDelayBeats}, feedback 0..${SCORE_LIMITS.maxDelayFeedback}, mix 0..${SCORE_LIMITS.maxDelayMix}. reverb: mix 0..${SCORE_LIMITS.maxReverbMix} (0.15..0.35 is a natural room), size ${SCORE_LIMITS.minReverbSize}..${SCORE_LIMITS.maxReverbSize} (default 0.5; higher is a longer, darker tail).`,
|
|
504
|
+
parameters: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
trackId: trackIdSchema,
|
|
508
|
+
filter: {
|
|
509
|
+
anyOf: [
|
|
510
|
+
{
|
|
511
|
+
type: "object",
|
|
512
|
+
properties: {
|
|
513
|
+
cutoff: {
|
|
514
|
+
type: "number",
|
|
515
|
+
minimum: SCORE_LIMITS.minFilterCutoff,
|
|
516
|
+
maximum: SCORE_LIMITS.maxFilterCutoff,
|
|
517
|
+
},
|
|
518
|
+
resonance: {
|
|
519
|
+
type: "number",
|
|
520
|
+
minimum: 0,
|
|
521
|
+
maximum: SCORE_LIMITS.maxFilterResonance,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
required: ["cutoff"],
|
|
525
|
+
additionalProperties: false,
|
|
526
|
+
},
|
|
527
|
+
{ type: "null" },
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
delay: {
|
|
531
|
+
anyOf: [
|
|
532
|
+
{
|
|
533
|
+
type: "object",
|
|
534
|
+
properties: {
|
|
535
|
+
beats: {
|
|
536
|
+
type: "number",
|
|
537
|
+
minimum: SCORE_LIMITS.minDelayBeats,
|
|
538
|
+
maximum: SCORE_LIMITS.maxDelayBeats,
|
|
539
|
+
},
|
|
540
|
+
feedback: {
|
|
541
|
+
type: "number",
|
|
542
|
+
minimum: 0,
|
|
543
|
+
maximum: SCORE_LIMITS.maxDelayFeedback,
|
|
544
|
+
},
|
|
545
|
+
mix: {
|
|
546
|
+
type: "number",
|
|
547
|
+
minimum: 0,
|
|
548
|
+
maximum: SCORE_LIMITS.maxDelayMix,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
required: ["beats"],
|
|
552
|
+
additionalProperties: false,
|
|
553
|
+
},
|
|
554
|
+
{ type: "null" },
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
reverb: {
|
|
558
|
+
anyOf: [
|
|
559
|
+
{
|
|
560
|
+
type: "object",
|
|
561
|
+
properties: {
|
|
562
|
+
mix: {
|
|
563
|
+
type: "number",
|
|
564
|
+
minimum: 0,
|
|
565
|
+
maximum: SCORE_LIMITS.maxReverbMix,
|
|
566
|
+
},
|
|
567
|
+
size: {
|
|
568
|
+
type: "number",
|
|
569
|
+
minimum: SCORE_LIMITS.minReverbSize,
|
|
570
|
+
maximum: SCORE_LIMITS.maxReverbSize,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
required: ["mix"],
|
|
574
|
+
additionalProperties: false,
|
|
575
|
+
},
|
|
576
|
+
{ type: "null" },
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
additionalProperties: false,
|
|
581
|
+
},
|
|
582
|
+
plan(args, context) {
|
|
583
|
+
const trackId = targetTrack(args, context);
|
|
584
|
+
const patch: {
|
|
585
|
+
filter?: { cutoff: number; resonance: number } | null;
|
|
586
|
+
delay?: { beats: number; feedback: number; mix: number } | null;
|
|
587
|
+
reverb?: { mix: number; size: number } | null;
|
|
588
|
+
} = {};
|
|
589
|
+
const parts: string[] = [];
|
|
590
|
+
if (args.filter === null) {
|
|
591
|
+
patch.filter = null;
|
|
592
|
+
parts.push("filter off");
|
|
593
|
+
} else if (args.filter !== undefined) {
|
|
594
|
+
const filter = record(args.filter, "filter");
|
|
595
|
+
patch.filter = {
|
|
596
|
+
cutoff: number(filter, "cutoff", {
|
|
597
|
+
min: SCORE_LIMITS.minFilterCutoff,
|
|
598
|
+
max: SCORE_LIMITS.maxFilterCutoff,
|
|
599
|
+
}),
|
|
600
|
+
resonance:
|
|
601
|
+
optionalNumber(filter, "resonance", {
|
|
602
|
+
min: 0,
|
|
603
|
+
max: SCORE_LIMITS.maxFilterResonance,
|
|
604
|
+
}) ?? 0,
|
|
605
|
+
};
|
|
606
|
+
parts.push(`filter ${Math.round(patch.filter.cutoff)} Hz`);
|
|
607
|
+
}
|
|
608
|
+
if (args.delay === null) {
|
|
609
|
+
patch.delay = null;
|
|
610
|
+
parts.push("delay off");
|
|
611
|
+
} else if (args.delay !== undefined) {
|
|
612
|
+
const delay = record(args.delay, "delay");
|
|
613
|
+
patch.delay = {
|
|
614
|
+
beats: number(delay, "beats", {
|
|
615
|
+
min: SCORE_LIMITS.minDelayBeats,
|
|
616
|
+
max: SCORE_LIMITS.maxDelayBeats,
|
|
617
|
+
}),
|
|
618
|
+
feedback:
|
|
619
|
+
optionalNumber(delay, "feedback", {
|
|
620
|
+
min: 0,
|
|
621
|
+
max: SCORE_LIMITS.maxDelayFeedback,
|
|
622
|
+
}) ?? 0.35,
|
|
623
|
+
mix:
|
|
624
|
+
optionalNumber(delay, "mix", {
|
|
625
|
+
min: 0,
|
|
626
|
+
max: SCORE_LIMITS.maxDelayMix,
|
|
627
|
+
}) ?? 0.3,
|
|
628
|
+
};
|
|
629
|
+
parts.push(`delay ${patch.delay.beats} beats`);
|
|
630
|
+
}
|
|
631
|
+
if (args.reverb === null) {
|
|
632
|
+
patch.reverb = null;
|
|
633
|
+
parts.push("reverb off");
|
|
634
|
+
} else if (args.reverb !== undefined) {
|
|
635
|
+
const reverb = record(args.reverb, "reverb");
|
|
636
|
+
patch.reverb = {
|
|
637
|
+
mix: number(reverb, "mix", {
|
|
638
|
+
min: 0,
|
|
639
|
+
max: SCORE_LIMITS.maxReverbMix,
|
|
640
|
+
}),
|
|
641
|
+
size:
|
|
642
|
+
optionalNumber(reverb, "size", {
|
|
643
|
+
min: SCORE_LIMITS.minReverbSize,
|
|
644
|
+
max: SCORE_LIMITS.maxReverbSize,
|
|
645
|
+
}) ?? 0.5,
|
|
646
|
+
};
|
|
647
|
+
parts.push(`reverb ${patch.reverb.mix}`);
|
|
648
|
+
}
|
|
649
|
+
if (parts.length === 0)
|
|
650
|
+
throw new ToolArgumentError(
|
|
651
|
+
"set_effects needs filter, delay, or reverb",
|
|
652
|
+
);
|
|
653
|
+
return {
|
|
654
|
+
kind: "score",
|
|
655
|
+
operations: [{ type: "updateTrack", trackId, patch }],
|
|
656
|
+
trackId,
|
|
657
|
+
summary: `${trackId} ${parts.join(", ")}`,
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "add_drums",
|
|
663
|
+
description: `Add drum hits to a kit track (create one with create_track instrument "kit"). Voices: ${DRUM_VOICES.map((info) => info.voice).join(", ")}. Give explicit hits, and/or patterns that repeat a voice every N beats across the loop.`,
|
|
664
|
+
parameters: {
|
|
665
|
+
type: "object",
|
|
666
|
+
properties: {
|
|
667
|
+
trackId: trackIdSchema,
|
|
668
|
+
hits: {
|
|
669
|
+
type: "array",
|
|
670
|
+
maxItems: MAX_NOTES_PER_CALL,
|
|
671
|
+
items: {
|
|
672
|
+
type: "object",
|
|
673
|
+
properties: {
|
|
674
|
+
voice: {
|
|
675
|
+
type: "string",
|
|
676
|
+
enum: DRUM_VOICES.map((info) => info.voice),
|
|
677
|
+
},
|
|
678
|
+
beat: beatSchema("Hit position in beats"),
|
|
679
|
+
velocity: { type: "number", minimum: 0, maximum: 1 },
|
|
680
|
+
},
|
|
681
|
+
required: ["voice", "beat"],
|
|
682
|
+
additionalProperties: false,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
patterns: {
|
|
686
|
+
type: "array",
|
|
687
|
+
maxItems: 8,
|
|
688
|
+
items: {
|
|
689
|
+
type: "object",
|
|
690
|
+
properties: {
|
|
691
|
+
voice: {
|
|
692
|
+
type: "string",
|
|
693
|
+
enum: DRUM_VOICES.map((info) => info.voice),
|
|
694
|
+
},
|
|
695
|
+
every: {
|
|
696
|
+
type: "number",
|
|
697
|
+
minimum: 0.125,
|
|
698
|
+
description: "Step in beats",
|
|
699
|
+
},
|
|
700
|
+
from: beatSchema("First hit in beats (default 0)"),
|
|
701
|
+
velocity: { type: "number", minimum: 0, maximum: 1 },
|
|
702
|
+
},
|
|
703
|
+
required: ["voice", "every"],
|
|
704
|
+
additionalProperties: false,
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
additionalProperties: false,
|
|
709
|
+
},
|
|
710
|
+
plan(args, context) {
|
|
711
|
+
const trackId = targetTrack(args, context);
|
|
712
|
+
const track = context.score.tracks.find((t) => t.id === trackId)!;
|
|
713
|
+
if (!isDrumInstrument(track.instrument))
|
|
714
|
+
throw new ToolArgumentError(
|
|
715
|
+
`track ${trackId} is ${track.instrument}, not a drum kit; set_instrument kit or create a kit track`,
|
|
716
|
+
);
|
|
717
|
+
const tpb = context.score.ticksPerBeat;
|
|
718
|
+
const loopBeats = context.score.bars * context.score.beatsPerBar;
|
|
719
|
+
const voiceOf = (value: unknown, label: string) => {
|
|
720
|
+
const voice =
|
|
721
|
+
typeof value === "string" ? parseDrumVoice(value) : undefined;
|
|
722
|
+
if (!voice) throw new ToolArgumentError(`${label}: unknown drum voice`);
|
|
723
|
+
return voice;
|
|
724
|
+
};
|
|
725
|
+
const hits: { voice: DrumVoice; beat: number; velocity: number }[] = [];
|
|
726
|
+
for (const [index, value] of (args.hits === undefined
|
|
727
|
+
? []
|
|
728
|
+
: list(args, "hits", 0, MAX_NOTES_PER_CALL)
|
|
729
|
+
).entries()) {
|
|
730
|
+
const hit = record(value, `hits[${index}]`);
|
|
731
|
+
hits.push({
|
|
732
|
+
voice: voiceOf(hit.voice, `hits[${index}].voice`),
|
|
733
|
+
beat: number(hit, "beat", { min: 0 }),
|
|
734
|
+
velocity: optionalNumber(hit, "velocity", { min: 0, max: 1 }) ?? 0.9,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
for (const [index, value] of (args.patterns === undefined
|
|
738
|
+
? []
|
|
739
|
+
: list(args, "patterns", 0, 8)
|
|
740
|
+
).entries()) {
|
|
741
|
+
const pattern = record(value, `patterns[${index}]`);
|
|
742
|
+
const voice = voiceOf(pattern.voice, `patterns[${index}].voice`);
|
|
743
|
+
const every = number(pattern, "every", { min: 0.125 });
|
|
744
|
+
const from = optionalNumber(pattern, "from", { min: 0 }) ?? 0;
|
|
745
|
+
const velocity =
|
|
746
|
+
optionalNumber(pattern, "velocity", { min: 0, max: 1 }) ?? 0.85;
|
|
747
|
+
for (let beat = from; beat < loopBeats - 1e-9; beat += every) {
|
|
748
|
+
hits.push({ voice, beat, velocity });
|
|
749
|
+
if (hits.length > MAX_NOTES_PER_CALL)
|
|
750
|
+
throw new ToolArgumentError(
|
|
751
|
+
`add_drums is limited to ${MAX_NOTES_PER_CALL} hits per call`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (hits.length === 0)
|
|
756
|
+
throw new ToolArgumentError("add_drums needs hits or patterns");
|
|
757
|
+
const operations = hits.map((hit, index): ScoreOperation => ({
|
|
758
|
+
type: "addNote",
|
|
759
|
+
note: {
|
|
760
|
+
id: context.newNoteId(trackId, index),
|
|
761
|
+
trackId,
|
|
762
|
+
startTick: Math.round(hit.beat * tpb),
|
|
763
|
+
durationTicks: Math.max(1, Math.round(tpb / 4)),
|
|
764
|
+
pitch: drumVoicePitch(hit.voice),
|
|
765
|
+
velocity: hit.velocity,
|
|
766
|
+
},
|
|
767
|
+
}));
|
|
768
|
+
return {
|
|
769
|
+
kind: "score",
|
|
770
|
+
operations,
|
|
771
|
+
trackId,
|
|
772
|
+
summary: `+${operations.length} ${trackId} hit${operations.length === 1 ? "" : "s"}`,
|
|
773
|
+
};
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: "extend_loop",
|
|
778
|
+
description:
|
|
779
|
+
"Resize the loop: bars sets the total, addBars appends. Notes and automation are preserved.",
|
|
780
|
+
parameters: {
|
|
781
|
+
type: "object",
|
|
782
|
+
properties: {
|
|
783
|
+
bars: { type: "integer", minimum: 1, maximum: SCORE_LIMITS.maxBars },
|
|
784
|
+
addBars: {
|
|
785
|
+
type: "integer",
|
|
786
|
+
minimum: 1,
|
|
787
|
+
maximum: SCORE_LIMITS.maxBars,
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
additionalProperties: false,
|
|
791
|
+
},
|
|
792
|
+
plan(args, context) {
|
|
793
|
+
const current = context.score.bars;
|
|
794
|
+
const add = optionalNumber(args, "addBars", {
|
|
795
|
+
min: 1,
|
|
796
|
+
max: SCORE_LIMITS.maxBars,
|
|
797
|
+
integer: true,
|
|
798
|
+
});
|
|
799
|
+
const total = optionalNumber(args, "bars", {
|
|
800
|
+
min: 1,
|
|
801
|
+
max: SCORE_LIMITS.maxBars,
|
|
802
|
+
integer: true,
|
|
803
|
+
});
|
|
804
|
+
if ((add === undefined) === (total === undefined))
|
|
805
|
+
throw new ToolArgumentError(
|
|
806
|
+
"extend_loop needs exactly one of bars or addBars",
|
|
807
|
+
);
|
|
808
|
+
const bars = total ?? current + add!;
|
|
809
|
+
if (bars > SCORE_LIMITS.maxBars)
|
|
810
|
+
throw new ToolArgumentError(
|
|
811
|
+
`loop cannot exceed ${SCORE_LIMITS.maxBars} bars`,
|
|
812
|
+
);
|
|
813
|
+
return {
|
|
814
|
+
kind: "score",
|
|
815
|
+
operations: [{ type: "setBars", bars }],
|
|
816
|
+
summary: `loop ${current} → ${bars} bars`,
|
|
817
|
+
};
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: "set_tempo",
|
|
822
|
+
description: "Set the session tempo in BPM.",
|
|
823
|
+
parameters: {
|
|
824
|
+
type: "object",
|
|
825
|
+
properties: {
|
|
826
|
+
bpm: {
|
|
827
|
+
type: "number",
|
|
828
|
+
minimum: SCORE_LIMITS.minTempoBpm,
|
|
829
|
+
maximum: SCORE_LIMITS.maxTempoBpm,
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
required: ["bpm"],
|
|
833
|
+
additionalProperties: false,
|
|
834
|
+
},
|
|
835
|
+
plan(args, context) {
|
|
836
|
+
const bpm = number(args, "bpm", {
|
|
837
|
+
min: SCORE_LIMITS.minTempoBpm,
|
|
838
|
+
max: SCORE_LIMITS.maxTempoBpm,
|
|
839
|
+
});
|
|
840
|
+
return {
|
|
841
|
+
kind: "score",
|
|
842
|
+
operations: [{ type: "setTempo", tempoBpm: bpm }],
|
|
843
|
+
summary: `tempo ${context.score.tempoBpm} → ${bpm} BPM`,
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "create_track",
|
|
849
|
+
description:
|
|
850
|
+
"Create a new track, then add notes to it with add_notes using the same id.",
|
|
851
|
+
parameters: {
|
|
852
|
+
type: "object",
|
|
853
|
+
properties: {
|
|
854
|
+
id: { type: "string", pattern: ID_PATTERN.source, maxLength: 64 },
|
|
855
|
+
name: { type: "string", maxLength: SCORE_LIMITS.maxNameLength },
|
|
856
|
+
instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
|
|
857
|
+
},
|
|
858
|
+
required: ["id", "instrument"],
|
|
859
|
+
additionalProperties: false,
|
|
860
|
+
},
|
|
861
|
+
plan(args, context) {
|
|
862
|
+
if (typeof args.id !== "string" || !ID_PATTERN.test(args.id))
|
|
863
|
+
throw new ToolArgumentError(
|
|
864
|
+
"id must be 1-64 letters, digits, dot, dash, or underscore",
|
|
865
|
+
);
|
|
866
|
+
const id = args.id;
|
|
867
|
+
if (context.score.tracks.some((track) => track.id === id))
|
|
868
|
+
throw new ToolArgumentError(`track ${id} already exists`);
|
|
869
|
+
const instrument = instrumentName(args.instrument);
|
|
870
|
+
const name =
|
|
871
|
+
typeof args.name === "string" && args.name.trim().length > 0
|
|
872
|
+
? args.name.trim().slice(0, SCORE_LIMITS.maxNameLength)
|
|
873
|
+
: id;
|
|
874
|
+
return {
|
|
875
|
+
kind: "score",
|
|
876
|
+
operations: [{ type: "addTrack", track: { id, name, instrument } }],
|
|
877
|
+
trackId: id,
|
|
878
|
+
summary: `+track ${id} (${instrument})`,
|
|
879
|
+
};
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
name: "transport",
|
|
884
|
+
description: "Start, stop, or toggle shared playback.",
|
|
885
|
+
parameters: {
|
|
886
|
+
type: "object",
|
|
887
|
+
properties: {
|
|
888
|
+
action: { type: "string", enum: ["play", "pause", "toggle"] },
|
|
889
|
+
},
|
|
890
|
+
required: ["action"],
|
|
891
|
+
additionalProperties: false,
|
|
892
|
+
},
|
|
893
|
+
plan(args) {
|
|
894
|
+
const action = oneOf(args.action, ["play", "pause", "toggle"], "action");
|
|
895
|
+
return { kind: "transport", action, summary: action };
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
name: "explain",
|
|
900
|
+
description:
|
|
901
|
+
"Tell the user briefly what you changed or why, without editing the score.",
|
|
902
|
+
parameters: {
|
|
903
|
+
type: "object",
|
|
904
|
+
properties: { text: { type: "string", maxLength: MAX_EXPLAIN_CHARS } },
|
|
905
|
+
required: ["text"],
|
|
906
|
+
additionalProperties: false,
|
|
907
|
+
},
|
|
908
|
+
plan(args) {
|
|
909
|
+
if (typeof args.text !== "string" || args.text.trim().length === 0)
|
|
910
|
+
throw new ToolArgumentError("text must be a non-empty string");
|
|
911
|
+
const text = args.text.trim().slice(0, MAX_EXPLAIN_CHARS);
|
|
912
|
+
return { kind: "explain", text, summary: text.slice(0, 80) };
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
name: "list_files",
|
|
917
|
+
description:
|
|
918
|
+
"List a project directory (default: the project root): directories first, then files with size and mtime. At most 500 entries; .dawg/ is hidden.",
|
|
919
|
+
parameters: {
|
|
920
|
+
type: "object",
|
|
921
|
+
properties: { path: pathSchema("Project-relative directory") },
|
|
922
|
+
additionalProperties: false,
|
|
923
|
+
},
|
|
924
|
+
plan(args, context) {
|
|
925
|
+
const path = optionalPath(args);
|
|
926
|
+
return {
|
|
927
|
+
kind: "action",
|
|
928
|
+
summary: `list ${path || "."}`,
|
|
929
|
+
run: async (action) => {
|
|
930
|
+
const scope = workspaceScope(action, context);
|
|
931
|
+
const result = await listFiles(scope, path);
|
|
932
|
+
return { content: result.text, summary: result.summary };
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
name: "read_file",
|
|
939
|
+
description: `Read a UTF-8 text file from the project by line range (offset is 1-based, limit is a line count). Output is capped at ${WORKSPACE_LIMITS.maxReadBytes / 1024} KiB; binary files report size and type.`,
|
|
940
|
+
parameters: {
|
|
941
|
+
type: "object",
|
|
942
|
+
properties: {
|
|
943
|
+
path: pathSchema("Project-relative file"),
|
|
944
|
+
offset: {
|
|
945
|
+
type: "integer",
|
|
946
|
+
minimum: 1,
|
|
947
|
+
description: "First line (1-based)",
|
|
948
|
+
},
|
|
949
|
+
limit: {
|
|
950
|
+
type: "integer",
|
|
951
|
+
minimum: 1,
|
|
952
|
+
maximum: WORKSPACE_LIMITS.maxReadLines,
|
|
953
|
+
description: "Number of lines",
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
required: ["path"],
|
|
957
|
+
additionalProperties: false,
|
|
958
|
+
},
|
|
959
|
+
plan(args, context) {
|
|
960
|
+
const path = requiredPath(args);
|
|
961
|
+
const offset = optionalNumber(args, "offset", { min: 1, integer: true });
|
|
962
|
+
const limit = optionalNumber(args, "limit", {
|
|
963
|
+
min: 1,
|
|
964
|
+
max: WORKSPACE_LIMITS.maxReadLines,
|
|
965
|
+
integer: true,
|
|
966
|
+
});
|
|
967
|
+
return {
|
|
968
|
+
kind: "action",
|
|
969
|
+
summary: `read ${path}`,
|
|
970
|
+
run: async (action) => {
|
|
971
|
+
const scope = workspaceScope(action, context);
|
|
972
|
+
const result = await readFile(scope, path, {
|
|
973
|
+
...(offset !== undefined ? { offset } : {}),
|
|
974
|
+
...(limit !== undefined ? { limit } : {}),
|
|
975
|
+
});
|
|
976
|
+
return { content: result.text, summary: result.summary };
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "write_file",
|
|
983
|
+
description: `Create or replace a file atomically with the full content (at most ${WORKSPACE_LIMITS.maxWriteBytes / 1024 / 1024} MiB). Writable: song.ts and the focused track's tracks/<slug>/ directory, where notes.md is your scratchpad. Parent directories are created.`,
|
|
984
|
+
parameters: {
|
|
985
|
+
type: "object",
|
|
986
|
+
properties: {
|
|
987
|
+
path: pathSchema("Project-relative file"),
|
|
988
|
+
content: { type: "string", description: "Entire new file content" },
|
|
989
|
+
},
|
|
990
|
+
required: ["path", "content"],
|
|
991
|
+
additionalProperties: false,
|
|
992
|
+
},
|
|
993
|
+
plan(args, context) {
|
|
994
|
+
const path = requiredPath(args);
|
|
995
|
+
if (typeof args.content !== "string")
|
|
996
|
+
throw new ToolArgumentError("content must be a string");
|
|
997
|
+
const content = args.content;
|
|
998
|
+
return {
|
|
999
|
+
kind: "action",
|
|
1000
|
+
summary: `write ${path}`,
|
|
1001
|
+
run: async (action) => {
|
|
1002
|
+
const scope = workspaceScope(action, context);
|
|
1003
|
+
const result = await writeFile(scope, path, content);
|
|
1004
|
+
return {
|
|
1005
|
+
content: await afterWrite(action, result.rel, result.text),
|
|
1006
|
+
summary: result.summary,
|
|
1007
|
+
mutated: true,
|
|
1008
|
+
};
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
name: "edit_file",
|
|
1015
|
+
description:
|
|
1016
|
+
"Replace one exact occurrence of old with new in a writable file (same scope as write_file). old must match exactly once; otherwise the result tells you the count. Prefer this over many note tools for large edits or restructuring of tracks/<slug>/track.ts.",
|
|
1017
|
+
parameters: {
|
|
1018
|
+
type: "object",
|
|
1019
|
+
properties: {
|
|
1020
|
+
path: pathSchema("Project-relative file"),
|
|
1021
|
+
old: {
|
|
1022
|
+
type: "string",
|
|
1023
|
+
minLength: 1,
|
|
1024
|
+
description: "Exact text to replace",
|
|
1025
|
+
},
|
|
1026
|
+
new: { type: "string", description: "Replacement text" },
|
|
1027
|
+
},
|
|
1028
|
+
required: ["path", "old", "new"],
|
|
1029
|
+
additionalProperties: false,
|
|
1030
|
+
},
|
|
1031
|
+
plan(args, context) {
|
|
1032
|
+
const path = requiredPath(args);
|
|
1033
|
+
if (typeof args.old !== "string" || args.old.length === 0)
|
|
1034
|
+
throw new ToolArgumentError("old must be a non-empty string");
|
|
1035
|
+
if (typeof args.new !== "string")
|
|
1036
|
+
throw new ToolArgumentError("new must be a string");
|
|
1037
|
+
const { old: oldText, new: newText } = args;
|
|
1038
|
+
return {
|
|
1039
|
+
kind: "action",
|
|
1040
|
+
summary: `edit ${path}`,
|
|
1041
|
+
run: async (action) => {
|
|
1042
|
+
const scope = workspaceScope(action, context);
|
|
1043
|
+
const result = await editFile(scope, path, oldText, newText);
|
|
1044
|
+
return {
|
|
1045
|
+
content: await afterWrite(action, result.rel, result.text),
|
|
1046
|
+
summary: result.summary,
|
|
1047
|
+
mutated: true,
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: "web_search",
|
|
1055
|
+
description: `Search the web; returns up to ${SEARCH_LIMITS.defaultCount} results as title, url and snippet. Follow up with fetch_url to read a page.`,
|
|
1056
|
+
parameters: {
|
|
1057
|
+
type: "object",
|
|
1058
|
+
properties: {
|
|
1059
|
+
query: { type: "string", maxLength: SEARCH_LIMITS.maxQueryChars },
|
|
1060
|
+
count: { type: "integer", minimum: 1, maximum: SEARCH_LIMITS.maxCount },
|
|
1061
|
+
},
|
|
1062
|
+
required: ["query"],
|
|
1063
|
+
additionalProperties: false,
|
|
1064
|
+
},
|
|
1065
|
+
plan(args) {
|
|
1066
|
+
if (typeof args.query !== "string" || args.query.trim().length === 0)
|
|
1067
|
+
throw new ToolArgumentError("query must be a non-empty string");
|
|
1068
|
+
if (args.query.length > SEARCH_LIMITS.maxQueryChars)
|
|
1069
|
+
throw new ToolArgumentError(
|
|
1070
|
+
`query must be at most ${SEARCH_LIMITS.maxQueryChars} characters`,
|
|
1071
|
+
);
|
|
1072
|
+
const query = args.query.trim();
|
|
1073
|
+
const count = optionalNumber(args, "count", {
|
|
1074
|
+
min: 1,
|
|
1075
|
+
max: SEARCH_LIMITS.maxCount,
|
|
1076
|
+
integer: true,
|
|
1077
|
+
});
|
|
1078
|
+
return {
|
|
1079
|
+
kind: "action",
|
|
1080
|
+
summary: `search ${query.slice(0, 60)}`,
|
|
1081
|
+
run: async (action) => {
|
|
1082
|
+
const web = action.web ?? {};
|
|
1083
|
+
const outcome = await webSearch(query, {
|
|
1084
|
+
...(count !== undefined ? { count } : {}),
|
|
1085
|
+
braveApiKey: web.braveApiKey ?? process.env.BRAVE_SEARCH_API_KEY,
|
|
1086
|
+
...(web.gatewayApiKey ? { gatewayApiKey: web.gatewayApiKey } : {}),
|
|
1087
|
+
...(web.gatewayBaseUrl
|
|
1088
|
+
? { gatewayBaseUrl: web.gatewayBaseUrl }
|
|
1089
|
+
: {}),
|
|
1090
|
+
searchTool: web.searchTool ?? process.env.DAWG_WEB_SEARCH,
|
|
1091
|
+
openRouterApiKey:
|
|
1092
|
+
web.openRouterApiKey ?? process.env.OPENROUTER_API_KEY,
|
|
1093
|
+
...(web.openRouterBaseUrl
|
|
1094
|
+
? { openRouterBaseUrl: web.openRouterBaseUrl }
|
|
1095
|
+
: {}),
|
|
1096
|
+
...(web.fetch ? { fetch: web.fetch } : {}),
|
|
1097
|
+
...(web.onSpend ? { onSpend: web.onSpend } : {}),
|
|
1098
|
+
...(action.signal ? { signal: action.signal } : {}),
|
|
1099
|
+
});
|
|
1100
|
+
const via = describeSearchProvider(outcome);
|
|
1101
|
+
return {
|
|
1102
|
+
content: formatSearchResults(outcome, query),
|
|
1103
|
+
summary: `searched via ${via}${outcome.fallbackFrom ? " (fallback)" : ""} · ${outcome.results.length} result${outcome.results.length === 1 ? "" : "s"}`,
|
|
1104
|
+
};
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
name: "fetch_url",
|
|
1111
|
+
description: `Fetch a public http(s) page and return its readable text (HTML reduced to headings, text and links; at most ${FETCH_LIMITS.maxOutputChars / 1024} KiB). Private and local addresses are refused.`,
|
|
1112
|
+
parameters: {
|
|
1113
|
+
type: "object",
|
|
1114
|
+
properties: {
|
|
1115
|
+
url: { type: "string", maxLength: FETCH_LIMITS.maxUrlChars },
|
|
1116
|
+
},
|
|
1117
|
+
required: ["url"],
|
|
1118
|
+
additionalProperties: false,
|
|
1119
|
+
},
|
|
1120
|
+
plan(args) {
|
|
1121
|
+
if (typeof args.url !== "string" || args.url.trim().length === 0)
|
|
1122
|
+
throw new ToolArgumentError("url must be a non-empty string");
|
|
1123
|
+
if (args.url.length > FETCH_LIMITS.maxUrlChars)
|
|
1124
|
+
throw new ToolArgumentError(
|
|
1125
|
+
`url must be at most ${FETCH_LIMITS.maxUrlChars} characters`,
|
|
1126
|
+
);
|
|
1127
|
+
const url = args.url.trim();
|
|
1128
|
+
let host = url;
|
|
1129
|
+
try {
|
|
1130
|
+
host = new URL(url).hostname;
|
|
1131
|
+
} catch {
|
|
1132
|
+
// admitUrl reports the diagnostic when the action runs.
|
|
1133
|
+
}
|
|
1134
|
+
return {
|
|
1135
|
+
kind: "action",
|
|
1136
|
+
summary: `fetch ${host.slice(0, 60)}`,
|
|
1137
|
+
run: async (action) => {
|
|
1138
|
+
const page = await fetchUrl(url, {
|
|
1139
|
+
...(action.web?.fetch ? { fetch: action.web.fetch } : {}),
|
|
1140
|
+
...(action.web?.lookup ? { lookup: action.web.lookup } : {}),
|
|
1141
|
+
...(action.signal ? { signal: action.signal } : {}),
|
|
1142
|
+
});
|
|
1143
|
+
return {
|
|
1144
|
+
content: formatFetchedPage(page),
|
|
1145
|
+
summary: `fetched ${new URL(page.url).hostname} (${page.bytes} bytes${page.truncated ? ", truncated" : ""})`,
|
|
1146
|
+
};
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
...MEDIA_TOOLS,
|
|
1152
|
+
] satisfies AgentTool[]);
|
|
1153
|
+
|
|
1154
|
+
/** Errors an `action` plan may raise that are safe to show to the model. */
|
|
1155
|
+
export function isActionDiagnostic(error: unknown): boolean {
|
|
1156
|
+
return (
|
|
1157
|
+
error instanceof WorkspaceError ||
|
|
1158
|
+
error instanceof WebError ||
|
|
1159
|
+
error instanceof ToolArgumentError
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const MAX_HOOK_CHARS = 4_000;
|
|
1164
|
+
|
|
1165
|
+
async function afterWrite(
|
|
1166
|
+
action: ActionContext,
|
|
1167
|
+
rel: string,
|
|
1168
|
+
text: string,
|
|
1169
|
+
): Promise<string> {
|
|
1170
|
+
if (!action.onWorkspaceWrite) return text;
|
|
1171
|
+
try {
|
|
1172
|
+
const note = await action.onWorkspaceWrite(rel);
|
|
1173
|
+
return typeof note === "string" && note.trim()
|
|
1174
|
+
? `${text}\n${note.trim().slice(0, MAX_HOOK_CHARS)}`
|
|
1175
|
+
: text;
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
const message = (
|
|
1178
|
+
error instanceof Error ? error.message : String(error)
|
|
1179
|
+
).slice(0, MAX_HOOK_CHARS);
|
|
1180
|
+
return `${text}\nafter write: ${message}`;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** The slug of the focused track: its name, or its id for a draft track. */
|
|
1185
|
+
export function focusedTrackSlug(
|
|
1186
|
+
context: Pick<ToolContext, "score" | "focusedTrackId">,
|
|
1187
|
+
): string {
|
|
1188
|
+
const track = context.score.tracks.find(
|
|
1189
|
+
(candidate) => candidate.id === context.focusedTrackId,
|
|
1190
|
+
);
|
|
1191
|
+
return trackSlug(track?.name ?? context.focusedTrackId);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function workspaceScope(
|
|
1195
|
+
action: ActionContext,
|
|
1196
|
+
context: ToolContext,
|
|
1197
|
+
): WorkspaceScope {
|
|
1198
|
+
if (!action.workspace)
|
|
1199
|
+
throw new WorkspaceError("file tools are unavailable in this session");
|
|
1200
|
+
return { root: action.workspace.root, trackSlug: focusedTrackSlug(context) };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function pathSchema(description: string) {
|
|
1204
|
+
return {
|
|
1205
|
+
type: "string",
|
|
1206
|
+
maxLength: WORKSPACE_LIMITS.maxPathChars,
|
|
1207
|
+
description,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function requiredPath(args: Record<string, unknown>): string {
|
|
1212
|
+
const path = optionalPath(args);
|
|
1213
|
+
if (path === undefined || path.length === 0)
|
|
1214
|
+
throw new ToolArgumentError("path is required");
|
|
1215
|
+
return path;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function optionalPath(args: Record<string, unknown>): string | undefined {
|
|
1219
|
+
if (args.path === undefined) return undefined;
|
|
1220
|
+
if (typeof args.path !== "string")
|
|
1221
|
+
throw new ToolArgumentError("path must be a string");
|
|
1222
|
+
if (args.path.length > WORKSPACE_LIMITS.maxPathChars)
|
|
1223
|
+
throw new ToolArgumentError(
|
|
1224
|
+
`path must be at most ${WORKSPACE_LIMITS.maxPathChars} characters`,
|
|
1225
|
+
);
|
|
1226
|
+
return args.path;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const TOOLS_BY_NAME = new Map(AGENT_TOOLS.map((tool) => [tool.name, tool]));
|
|
1230
|
+
|
|
1231
|
+
export function findAgentTool(
|
|
1232
|
+
name: string,
|
|
1233
|
+
tools: readonly AgentTool[] = AGENT_TOOLS,
|
|
1234
|
+
): AgentTool | undefined {
|
|
1235
|
+
return tools === AGENT_TOOLS
|
|
1236
|
+
? TOOLS_BY_NAME.get(name)
|
|
1237
|
+
: tools.find((tool) => tool.name === name);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
export function chatTools(
|
|
1241
|
+
tools: readonly AgentTool[] = AGENT_TOOLS,
|
|
1242
|
+
): ChatTool[] {
|
|
1243
|
+
return tools.map((tool) => ({
|
|
1244
|
+
type: "function",
|
|
1245
|
+
function: {
|
|
1246
|
+
name: tool.name,
|
|
1247
|
+
description: tool.description,
|
|
1248
|
+
parameters: tool.parameters,
|
|
1249
|
+
},
|
|
1250
|
+
}));
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function targetTrack(args: Record<string, unknown>, context: ToolContext) {
|
|
1254
|
+
const trackId = args.trackId ?? context.focusedTrackId;
|
|
1255
|
+
if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
|
|
1256
|
+
throw new ToolArgumentError("trackId must be a short string");
|
|
1257
|
+
if (!context.score.tracks.some((track) => track.id === trackId))
|
|
1258
|
+
throw new ToolArgumentError(
|
|
1259
|
+
`unknown track ${trackId}; create it with create_track first`,
|
|
1260
|
+
);
|
|
1261
|
+
return trackId;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function knownNoteId(value: unknown, context: ToolContext, label: string) {
|
|
1265
|
+
if (typeof value !== "string" || value.length > SCORE_LIMITS.maxIdLength)
|
|
1266
|
+
throw new ToolArgumentError(`${label} must be a short string`);
|
|
1267
|
+
if (!context.score.notes.some((note) => note.id === value))
|
|
1268
|
+
throw new ToolArgumentError(`${label}: unknown note ${value}`);
|
|
1269
|
+
return value;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function instrumentName(value: unknown): string {
|
|
1273
|
+
if (
|
|
1274
|
+
typeof value !== "string" ||
|
|
1275
|
+
!(AVAILABLE_INSTRUMENTS as readonly string[]).includes(value)
|
|
1276
|
+
)
|
|
1277
|
+
throw new ToolArgumentError(
|
|
1278
|
+
`instrument must be one of ${AVAILABLE_INSTRUMENTS.join(", ")}`,
|
|
1279
|
+
);
|
|
1280
|
+
return value;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function pitch(value: unknown, label: string): number {
|
|
1284
|
+
const midi =
|
|
1285
|
+
typeof value === "string" ? pitchToMidi(value.trim().toLowerCase()) : value;
|
|
1286
|
+
if (
|
|
1287
|
+
typeof midi !== "number" ||
|
|
1288
|
+
!Number.isInteger(midi) ||
|
|
1289
|
+
midi < 0 ||
|
|
1290
|
+
midi > 127
|
|
1291
|
+
)
|
|
1292
|
+
throw new ToolArgumentError(
|
|
1293
|
+
`${label} must be MIDI 0..127 or a note name like C4`,
|
|
1294
|
+
);
|
|
1295
|
+
return midi;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function oneOf<T extends string>(
|
|
1299
|
+
value: unknown,
|
|
1300
|
+
options: readonly T[],
|
|
1301
|
+
label: string,
|
|
1302
|
+
): T {
|
|
1303
|
+
if (
|
|
1304
|
+
typeof value !== "string" ||
|
|
1305
|
+
!(options as readonly string[]).includes(value)
|
|
1306
|
+
)
|
|
1307
|
+
throw new ToolArgumentError(
|
|
1308
|
+
`${label} must be one of ${options.join(", ")}`,
|
|
1309
|
+
);
|
|
1310
|
+
return value as T;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
1314
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1315
|
+
throw new ToolArgumentError(`${label} must be an object`);
|
|
1316
|
+
return value as Record<string, unknown>;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function list(
|
|
1320
|
+
args: Record<string, unknown>,
|
|
1321
|
+
key: string,
|
|
1322
|
+
min: number,
|
|
1323
|
+
max: number,
|
|
1324
|
+
): unknown[] {
|
|
1325
|
+
const value = args[key];
|
|
1326
|
+
if (!Array.isArray(value) || value.length < min || value.length > max)
|
|
1327
|
+
throw new ToolArgumentError(
|
|
1328
|
+
`${key} must be an array of ${min}..${max} items`,
|
|
1329
|
+
);
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
type NumberBounds = {
|
|
1334
|
+
min?: number;
|
|
1335
|
+
max?: number;
|
|
1336
|
+
exclusive?: boolean;
|
|
1337
|
+
integer?: boolean;
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
function number(
|
|
1341
|
+
args: Record<string, unknown>,
|
|
1342
|
+
key: string,
|
|
1343
|
+
bounds: NumberBounds,
|
|
1344
|
+
): number {
|
|
1345
|
+
const value = optionalNumber(args, key, bounds);
|
|
1346
|
+
if (value === undefined) throw new ToolArgumentError(`${key} is required`);
|
|
1347
|
+
return value;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function optionalNumber(
|
|
1351
|
+
args: Record<string, unknown>,
|
|
1352
|
+
key: string,
|
|
1353
|
+
bounds: NumberBounds,
|
|
1354
|
+
): number | undefined {
|
|
1355
|
+
const value = args[key];
|
|
1356
|
+
if (value === undefined) return undefined;
|
|
1357
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1358
|
+
throw new ToolArgumentError(`${key} must be a finite number`);
|
|
1359
|
+
if (bounds.integer && !Number.isInteger(value))
|
|
1360
|
+
throw new ToolArgumentError(`${key} must be an integer`);
|
|
1361
|
+
if (
|
|
1362
|
+
bounds.min !== undefined &&
|
|
1363
|
+
(bounds.exclusive ? value <= bounds.min : value < bounds.min)
|
|
1364
|
+
)
|
|
1365
|
+
throw new ToolArgumentError(
|
|
1366
|
+
`${key} must be ${bounds.exclusive ? ">" : ">="} ${bounds.min}`,
|
|
1367
|
+
);
|
|
1368
|
+
if (bounds.max !== undefined && value > bounds.max)
|
|
1369
|
+
throw new ToolArgumentError(`${key} must be <= ${bounds.max}`);
|
|
1370
|
+
if (Math.abs(value) > SCORE_LIMITS.maxTick)
|
|
1371
|
+
throw new ToolArgumentError(`${key} is out of range`);
|
|
1372
|
+
return value;
|
|
1373
|
+
}
|