@hraness/dawg 0.2.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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -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 +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- 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 +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -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 +397 -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/media/wavetable.ts +202 -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 +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/agent/tools.ts
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
AUTOMATION_PARAMETERS,
|
|
3
|
+
automationPoints,
|
|
4
|
+
automationRange,
|
|
3
5
|
SCORE_LIMITS,
|
|
4
6
|
type AutomationParameter,
|
|
5
7
|
type ScoreOperation,
|
|
6
8
|
type TrackScore,
|
|
7
9
|
} from "../../core/score.ts";
|
|
8
10
|
import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
11
|
+
import {
|
|
12
|
+
SYNTH_PRESETS,
|
|
13
|
+
SYNTH_SIMPLE,
|
|
14
|
+
isSynthPreset,
|
|
15
|
+
synthParamName,
|
|
16
|
+
} from "../../core/synth.ts";
|
|
17
|
+
import { applySynthCommand, type SynthCommand } from "../commands/synth.ts";
|
|
18
|
+
import {
|
|
19
|
+
setSampleControls,
|
|
20
|
+
type SampleControlValue,
|
|
21
|
+
} from "../commands/sample.ts";
|
|
22
|
+
import {
|
|
23
|
+
EFFECT_NAMES,
|
|
24
|
+
FX_PRESETS,
|
|
25
|
+
effectSpec,
|
|
26
|
+
type EffectName,
|
|
27
|
+
} from "../../core/fx.ts";
|
|
28
|
+
import {
|
|
29
|
+
applyFxCommand,
|
|
30
|
+
effectPatch,
|
|
31
|
+
effectValues,
|
|
32
|
+
parseEffectName,
|
|
33
|
+
parseParamName,
|
|
34
|
+
type FxCommand,
|
|
35
|
+
} from "../commands/fx.ts";
|
|
9
36
|
import type { ChatTool } from "./gateway.ts";
|
|
37
|
+
import { MEDIA_TOOLS } from "../media/tools.ts";
|
|
38
|
+
import { PACK_TOOLS, PackToolError } from "./pack-tools.ts";
|
|
39
|
+
import {
|
|
40
|
+
PreviewToolError,
|
|
41
|
+
previewSoundTool,
|
|
42
|
+
type PreviewHost,
|
|
43
|
+
} from "./preview-tool.ts";
|
|
44
|
+
import { PackError, type PackStore } from "../audio/packs.ts";
|
|
45
|
+
import { RHYTHM_TOOLS } from "./rhythm-tools.ts";
|
|
46
|
+
import { CHORD_TOOLS } from "./chord-tools.ts";
|
|
47
|
+
import { DRUM_TOOLS } from "./drum-tools.ts";
|
|
48
|
+
import type { MediaResult, MediaRunContext } from "../media/types.ts";
|
|
10
49
|
import { pitchToMidi } from "./ops.ts";
|
|
11
50
|
import {
|
|
12
51
|
DRUM_VOICES,
|
|
@@ -15,8 +54,39 @@ import {
|
|
|
15
54
|
parseDrumVoice,
|
|
16
55
|
type DrumVoice,
|
|
17
56
|
} from "../../core/drums.ts";
|
|
57
|
+
import { trackSlug } from "../../core/slug.ts";
|
|
58
|
+
import {
|
|
59
|
+
editFile,
|
|
60
|
+
listFiles,
|
|
61
|
+
readFile,
|
|
62
|
+
WORKSPACE_LIMITS,
|
|
63
|
+
WorkspaceError,
|
|
64
|
+
writeFile,
|
|
65
|
+
type WorkspaceScope,
|
|
66
|
+
} from "./workspace.ts";
|
|
67
|
+
import {
|
|
68
|
+
fetchUrl,
|
|
69
|
+
FETCH_LIMITS,
|
|
70
|
+
formatFetchedPage,
|
|
71
|
+
type Lookup,
|
|
72
|
+
} from "../web/fetch.ts";
|
|
73
|
+
import { WebError, type FetchLike } from "../web/http.ts";
|
|
74
|
+
import {
|
|
75
|
+
describeSearchProvider,
|
|
76
|
+
formatSearchResults,
|
|
77
|
+
SEARCH_LIMITS,
|
|
78
|
+
webSearch,
|
|
79
|
+
type SearchSpend,
|
|
80
|
+
} from "../web/search.ts";
|
|
18
81
|
|
|
19
82
|
/** What a validated tool call asks the host to do. */
|
|
83
|
+
export type ScorePlan = Readonly<{
|
|
84
|
+
kind: "score";
|
|
85
|
+
operations: readonly ScoreOperation[];
|
|
86
|
+
summary: string;
|
|
87
|
+
trackId?: string;
|
|
88
|
+
}>;
|
|
89
|
+
|
|
20
90
|
export type ToolPlan =
|
|
21
91
|
| Readonly<{
|
|
22
92
|
kind: "score";
|
|
@@ -29,7 +99,79 @@ export type ToolPlan =
|
|
|
29
99
|
action: "play" | "pause" | "toggle";
|
|
30
100
|
summary: string;
|
|
31
101
|
}>
|
|
32
|
-
| Readonly<{ kind: "explain"; text: string; summary: string }
|
|
102
|
+
| Readonly<{ kind: "explain"; text: string; summary: string }>
|
|
103
|
+
| Readonly<{
|
|
104
|
+
kind: "action";
|
|
105
|
+
summary: string;
|
|
106
|
+
/** Side effects outside the score (files, network); bounded and async. */
|
|
107
|
+
run: (context: ActionContext) => Promise<ActionResult>;
|
|
108
|
+
}>
|
|
109
|
+
/**
|
|
110
|
+
* Score edits that need async work first (fetching a pack sound); `run`
|
|
111
|
+
* returns the score plan, committed like any other.
|
|
112
|
+
*/
|
|
113
|
+
| Readonly<{
|
|
114
|
+
kind: "prepare";
|
|
115
|
+
summary: string;
|
|
116
|
+
run: (context: ActionContext) => Promise<ScorePlan>;
|
|
117
|
+
}>
|
|
118
|
+
/** A long-running local media job (download, stems, analysis, …). */
|
|
119
|
+
| Readonly<{
|
|
120
|
+
kind: "media";
|
|
121
|
+
summary: string;
|
|
122
|
+
run: (context: MediaRunContext) => Promise<MediaResult>;
|
|
123
|
+
}>;
|
|
124
|
+
|
|
125
|
+
/** The project directory the workspace tools operate in. */
|
|
126
|
+
export type WorkspaceHost = Readonly<{ root: string }>;
|
|
127
|
+
|
|
128
|
+
/** Injection points for the web tools; defaults are the real network. */
|
|
129
|
+
export type WebHost = Readonly<{
|
|
130
|
+
fetch?: FetchLike;
|
|
131
|
+
lookup?: Lookup;
|
|
132
|
+
/** Brave Web Search API key; defaults to `BRAVE_SEARCH_API_KEY` and overrides the chain. */
|
|
133
|
+
braveApiKey?: string;
|
|
134
|
+
/** AI Gateway key of the active provider; enables gateway search tools. */
|
|
135
|
+
gatewayApiKey?: string;
|
|
136
|
+
gatewayBaseUrl?: string;
|
|
137
|
+
/** Gateway search tool; defaults to `DAWG_WEB_SEARCH`, then `exa`. */
|
|
138
|
+
searchTool?: string;
|
|
139
|
+
/** OpenRouter key; defaults to `OPENROUTER_API_KEY`. */
|
|
140
|
+
openRouterApiKey?: string;
|
|
141
|
+
openRouterBaseUrl?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Billed searches (gateway tools, OpenRouter web plugin) are reported here
|
|
144
|
+
* so the host can add them to its spend ledger (`webHostFor` in
|
|
145
|
+
* `src/agent/usage.ts`).
|
|
146
|
+
*/
|
|
147
|
+
onSpend?: (spend: SearchSpend) => void;
|
|
148
|
+
}>;
|
|
149
|
+
|
|
150
|
+
/** What an `action` plan receives from the host when it runs. */
|
|
151
|
+
export type ActionContext = Readonly<{
|
|
152
|
+
workspace?: WorkspaceHost;
|
|
153
|
+
/**
|
|
154
|
+
* Called after a successful `write_file`/`edit_file` with the
|
|
155
|
+
* project-relative path. Returned text (for example typecheck
|
|
156
|
+
* diagnostics) is appended to the tool result.
|
|
157
|
+
*/
|
|
158
|
+
onWorkspaceWrite?: (path: string) => Promise<string | void> | string | void;
|
|
159
|
+
web?: WebHost;
|
|
160
|
+
signal?: AbortSignal;
|
|
161
|
+
/** Sample packs for list_packs/search_sounds/use_sound; default the user cache. */
|
|
162
|
+
packs?: PackStore;
|
|
163
|
+
/** Render and play hooks for preview_sound; absent renders in-thread, silent. */
|
|
164
|
+
preview?: PreviewHost;
|
|
165
|
+
}>;
|
|
166
|
+
|
|
167
|
+
export type ActionResult = Readonly<{
|
|
168
|
+
/** What the model reads; already bounded by the tool. */
|
|
169
|
+
content: string;
|
|
170
|
+
/** One line for the activity card. */
|
|
171
|
+
summary: string;
|
|
172
|
+
/** True when something outside the score changed (a file write). */
|
|
173
|
+
mutated?: boolean;
|
|
174
|
+
}>;
|
|
33
175
|
|
|
34
176
|
export type ToolContext = Readonly<{
|
|
35
177
|
score: TrackScore;
|
|
@@ -54,10 +196,6 @@ export class ToolArgumentError extends Error {
|
|
|
54
196
|
}
|
|
55
197
|
}
|
|
56
198
|
|
|
57
|
-
const AUTOMATION_PARAMETERS = Object.keys(
|
|
58
|
-
AUTOMATION_LANES,
|
|
59
|
-
) as AutomationParameter[];
|
|
60
|
-
|
|
61
199
|
const MAX_NOTES_PER_CALL = 128;
|
|
62
200
|
const MAX_EXPLAIN_CHARS = 2_000;
|
|
63
201
|
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
@@ -337,12 +475,16 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
337
475
|
},
|
|
338
476
|
{
|
|
339
477
|
name: "set_automation",
|
|
340
|
-
description: `Write an automation lane: volume
|
|
478
|
+
description: `Write an automation lane: volume 0..1, pan -1..1, filter (cutoff Hz), resonance 0..${SCORE_LIMITS.maxFilterResonance}, delay-feedback 0..${SCORE_LIMITS.maxDelayFeedback}, delay-mix, wt (wavetable position 0..1), or <effect>-<param> (the param's range). The effect must be on to be heard. mode=replace (default) rewrites the lane; merge keeps other beats. An empty replace clears it.`,
|
|
341
479
|
parameters: {
|
|
342
480
|
type: "object",
|
|
343
481
|
properties: {
|
|
344
482
|
trackId: trackIdSchema,
|
|
345
|
-
parameter: {
|
|
483
|
+
parameter: {
|
|
484
|
+
type: "string",
|
|
485
|
+
description:
|
|
486
|
+
"volume, pan, filter, resonance, delay-feedback, delay-mix, or <effect>-<param>",
|
|
487
|
+
},
|
|
346
488
|
mode: { type: "string", enum: ["replace", "merge"] },
|
|
347
489
|
points: {
|
|
348
490
|
type: "array",
|
|
@@ -353,8 +495,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
353
495
|
beat: beatSchema("Beat position"),
|
|
354
496
|
value: {
|
|
355
497
|
type: "number",
|
|
356
|
-
description:
|
|
357
|
-
"volume 0..1, pan -1..1, filter cutoff in Hz, resonance 0..1, delay-feedback 0..0.9, delay-mix 0..1",
|
|
498
|
+
description: "in the lane's range",
|
|
358
499
|
},
|
|
359
500
|
},
|
|
360
501
|
required: ["beat", "value"],
|
|
@@ -376,7 +517,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
376
517
|
args.mode === undefined
|
|
377
518
|
? "replace"
|
|
378
519
|
: oneOf(args.mode, ["replace", "merge"], "mode");
|
|
379
|
-
const { min, max } =
|
|
520
|
+
const { min, max } = automationRange(parameter);
|
|
380
521
|
const tpb = context.score.ticksPerBeat;
|
|
381
522
|
const points = list(
|
|
382
523
|
args,
|
|
@@ -391,7 +532,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
391
532
|
};
|
|
392
533
|
});
|
|
393
534
|
const track = context.score.tracks.find((t) => t.id === trackId)!;
|
|
394
|
-
const existing = track
|
|
535
|
+
const existing = automationPoints(track, parameter);
|
|
395
536
|
const merged = Array.from(
|
|
396
537
|
new Map(
|
|
397
538
|
[...(mode === "merge" ? existing : []), ...points].map((point) => [
|
|
@@ -413,9 +554,153 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
413
554
|
};
|
|
414
555
|
},
|
|
415
556
|
},
|
|
557
|
+
{
|
|
558
|
+
name: "set_fx",
|
|
559
|
+
description:
|
|
560
|
+
"Turn an effect on/off, load a preset, or set params by dawg or Strudel name (lpq, delayfeedback…). on:true uses good defaults; effects and presets are in the brief.",
|
|
561
|
+
parameters: {
|
|
562
|
+
type: "object",
|
|
563
|
+
properties: {
|
|
564
|
+
trackId: trackIdSchema,
|
|
565
|
+
effect: { type: "string", description: "see the brief's effects" },
|
|
566
|
+
on: { type: "boolean" },
|
|
567
|
+
preset: { type: "string" },
|
|
568
|
+
params: {
|
|
569
|
+
type: "object",
|
|
570
|
+
additionalProperties: { type: ["number", "string", "boolean"] },
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
required: ["effect"],
|
|
574
|
+
additionalProperties: false,
|
|
575
|
+
},
|
|
576
|
+
plan(args, context) {
|
|
577
|
+
const trackId = targetTrack(args, context);
|
|
578
|
+
const effect =
|
|
579
|
+
typeof args.effect === "string"
|
|
580
|
+
? parseEffectName(args.effect)
|
|
581
|
+
: undefined;
|
|
582
|
+
if (!effect)
|
|
583
|
+
throw new ToolArgumentError(
|
|
584
|
+
`effect must be one of ${EFFECT_NAMES.join(", ")}`,
|
|
585
|
+
);
|
|
586
|
+
const command = fxToolCommand(effect, args);
|
|
587
|
+
const result = applyFxCommand(context.score, trackId, command);
|
|
588
|
+
if (!result.ok || !result.next)
|
|
589
|
+
throw new ToolArgumentError(result.message);
|
|
590
|
+
const track = context.score.tracks.find((t) => t.id === trackId)!;
|
|
591
|
+
const values = effectValues(
|
|
592
|
+
result.next.tracks.find((t) => t.id === trackId),
|
|
593
|
+
effect,
|
|
594
|
+
);
|
|
595
|
+
return {
|
|
596
|
+
kind: "score",
|
|
597
|
+
operations: [
|
|
598
|
+
{
|
|
599
|
+
type: "updateTrack",
|
|
600
|
+
trackId,
|
|
601
|
+
patch: effectPatch(track, effect, values ?? null),
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
trackId,
|
|
605
|
+
summary: `${trackId} ${result.message}`,
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
name: "set_synth",
|
|
611
|
+
description:
|
|
612
|
+
"Shape a synth track's voice with Strudel synth params (attack decay sustain release, lpf lpq lpenv, fm fmh, unison detune spread, vib vibmod, penv, noise, pw…); null unsets one. preset loads a voice (instrument + params); reset clears all; zzfx takes a raw ZzFX array (empty slots null) and sets a z_* sound.",
|
|
613
|
+
parameters: {
|
|
614
|
+
type: "object",
|
|
615
|
+
properties: {
|
|
616
|
+
trackId: trackIdSchema,
|
|
617
|
+
preset: { type: "string", enum: Object.keys(SYNTH_PRESETS) },
|
|
618
|
+
reset: { type: "boolean" },
|
|
619
|
+
zzfx: {
|
|
620
|
+
type: "array",
|
|
621
|
+
maxItems: 21,
|
|
622
|
+
items: { type: ["number", "null"] },
|
|
623
|
+
},
|
|
624
|
+
params: {
|
|
625
|
+
type: "object",
|
|
626
|
+
additionalProperties: {
|
|
627
|
+
type: ["number", "string", "boolean", "array", "null"],
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
additionalProperties: false,
|
|
632
|
+
},
|
|
633
|
+
plan(args, context) {
|
|
634
|
+
const trackId = targetTrack(args, context);
|
|
635
|
+
const command = synthToolCommand(args);
|
|
636
|
+
const result = applySynthCommand(context.score, trackId, command);
|
|
637
|
+
if (!result.ok || !result.next)
|
|
638
|
+
throw new ToolArgumentError(result.message);
|
|
639
|
+
const next = result.next.tracks.find((t) => t.id === trackId)!;
|
|
640
|
+
return {
|
|
641
|
+
kind: "score",
|
|
642
|
+
operations: [
|
|
643
|
+
{
|
|
644
|
+
type: "updateTrack",
|
|
645
|
+
trackId,
|
|
646
|
+
patch: { instrument: next.instrument, synth: next.synth ?? null },
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
trackId,
|
|
650
|
+
summary: `${trackId} ${result.message}`,
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "set_sample",
|
|
656
|
+
description:
|
|
657
|
+
"Set a sampler voice's Strudel sample controls: begin end gain speed unit(r|c|s) loop loopBegin loopEnd clip(legato) fit loopAt accelerate squiz cut; null unsets one.",
|
|
658
|
+
parameters: {
|
|
659
|
+
type: "object",
|
|
660
|
+
properties: {
|
|
661
|
+
trackId: trackIdSchema,
|
|
662
|
+
voice: { type: "string" },
|
|
663
|
+
params: {
|
|
664
|
+
type: "object",
|
|
665
|
+
additionalProperties: {
|
|
666
|
+
type: ["number", "string", "boolean", "null"],
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
required: ["voice", "params"],
|
|
671
|
+
additionalProperties: false,
|
|
672
|
+
},
|
|
673
|
+
plan(args, context) {
|
|
674
|
+
const trackId = targetTrack(args, context);
|
|
675
|
+
const params = args.params;
|
|
676
|
+
if (
|
|
677
|
+
typeof args.voice !== "string" ||
|
|
678
|
+
typeof params !== "object" ||
|
|
679
|
+
params === null ||
|
|
680
|
+
Array.isArray(params)
|
|
681
|
+
)
|
|
682
|
+
throw new ToolArgumentError("set_sample needs voice and params");
|
|
683
|
+
const result = setSampleControls(
|
|
684
|
+
context.score,
|
|
685
|
+
trackId,
|
|
686
|
+
args.voice,
|
|
687
|
+
params as Record<string, SampleControlValue>,
|
|
688
|
+
);
|
|
689
|
+
if (!result.ok) throw new ToolArgumentError(result.message);
|
|
690
|
+
const next = result.next.tracks.find((t) => t.id === trackId)!;
|
|
691
|
+
return {
|
|
692
|
+
kind: "score",
|
|
693
|
+
operations: [
|
|
694
|
+
{ type: "updateTrack", trackId, patch: { sampler: next.sampler } },
|
|
695
|
+
],
|
|
696
|
+
trackId,
|
|
697
|
+
summary: `${trackId} ${result.message}`,
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
},
|
|
416
701
|
{
|
|
417
702
|
name: "set_effects",
|
|
418
|
-
description: `
|
|
703
|
+
description: `Shorthand: low-pass filter, delay (beats) and reverb (mix 0.15..0.35 is a room); null removes one. Prefer set_fx.`,
|
|
419
704
|
parameters: {
|
|
420
705
|
type: "object",
|
|
421
706
|
properties: {
|
|
@@ -575,7 +860,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
575
860
|
},
|
|
576
861
|
{
|
|
577
862
|
name: "add_drums",
|
|
578
|
-
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.`,
|
|
863
|
+
description: `Add one-off drum hits to a kit track (create one with create_track instrument "kit"); prefer set_rhythm for repeating beats. Voices: ${DRUM_VOICES.map((info) => info.voice).join(", ")}. Give explicit hits, and/or patterns that repeat a voice every N beats across the loop.`,
|
|
579
864
|
parameters: {
|
|
580
865
|
type: "object",
|
|
581
866
|
properties: {
|
|
@@ -827,8 +1112,329 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
827
1112
|
return { kind: "explain", text, summary: text.slice(0, 80) };
|
|
828
1113
|
},
|
|
829
1114
|
},
|
|
1115
|
+
{
|
|
1116
|
+
name: "list_files",
|
|
1117
|
+
description:
|
|
1118
|
+
"List a project directory (default: the project root): directories first, then files with size and mtime. At most 500 entries; .dawg/ is hidden.",
|
|
1119
|
+
parameters: {
|
|
1120
|
+
type: "object",
|
|
1121
|
+
properties: { path: pathSchema("Project-relative directory") },
|
|
1122
|
+
additionalProperties: false,
|
|
1123
|
+
},
|
|
1124
|
+
plan(args, context) {
|
|
1125
|
+
const path = optionalPath(args);
|
|
1126
|
+
return {
|
|
1127
|
+
kind: "action",
|
|
1128
|
+
summary: `list ${path || "."}`,
|
|
1129
|
+
run: async (action) => {
|
|
1130
|
+
const scope = workspaceScope(action, context);
|
|
1131
|
+
const result = await listFiles(scope, path);
|
|
1132
|
+
return { content: result.text, summary: result.summary };
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
name: "read_file",
|
|
1139
|
+
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.`,
|
|
1140
|
+
parameters: {
|
|
1141
|
+
type: "object",
|
|
1142
|
+
properties: {
|
|
1143
|
+
path: pathSchema("Project-relative file"),
|
|
1144
|
+
offset: {
|
|
1145
|
+
type: "integer",
|
|
1146
|
+
minimum: 1,
|
|
1147
|
+
description: "First line (1-based)",
|
|
1148
|
+
},
|
|
1149
|
+
limit: {
|
|
1150
|
+
type: "integer",
|
|
1151
|
+
minimum: 1,
|
|
1152
|
+
maximum: WORKSPACE_LIMITS.maxReadLines,
|
|
1153
|
+
description: "Number of lines",
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
required: ["path"],
|
|
1157
|
+
additionalProperties: false,
|
|
1158
|
+
},
|
|
1159
|
+
plan(args, context) {
|
|
1160
|
+
const path = requiredPath(args);
|
|
1161
|
+
const offset = optionalNumber(args, "offset", { min: 1, integer: true });
|
|
1162
|
+
const limit = optionalNumber(args, "limit", {
|
|
1163
|
+
min: 1,
|
|
1164
|
+
max: WORKSPACE_LIMITS.maxReadLines,
|
|
1165
|
+
integer: true,
|
|
1166
|
+
});
|
|
1167
|
+
return {
|
|
1168
|
+
kind: "action",
|
|
1169
|
+
summary: `read ${path}`,
|
|
1170
|
+
run: async (action) => {
|
|
1171
|
+
const scope = workspaceScope(action, context);
|
|
1172
|
+
const result = await readFile(scope, path, {
|
|
1173
|
+
...(offset !== undefined ? { offset } : {}),
|
|
1174
|
+
...(limit !== undefined ? { limit } : {}),
|
|
1175
|
+
});
|
|
1176
|
+
return { content: result.text, summary: result.summary };
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: "write_file",
|
|
1183
|
+
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.`,
|
|
1184
|
+
parameters: {
|
|
1185
|
+
type: "object",
|
|
1186
|
+
properties: {
|
|
1187
|
+
path: pathSchema("Project-relative file"),
|
|
1188
|
+
content: { type: "string", description: "Entire new file content" },
|
|
1189
|
+
},
|
|
1190
|
+
required: ["path", "content"],
|
|
1191
|
+
additionalProperties: false,
|
|
1192
|
+
},
|
|
1193
|
+
plan(args, context) {
|
|
1194
|
+
const path = requiredPath(args);
|
|
1195
|
+
if (typeof args.content !== "string")
|
|
1196
|
+
throw new ToolArgumentError("content must be a string");
|
|
1197
|
+
const content = args.content;
|
|
1198
|
+
return {
|
|
1199
|
+
kind: "action",
|
|
1200
|
+
summary: `write ${path}`,
|
|
1201
|
+
run: async (action) => {
|
|
1202
|
+
const scope = workspaceScope(action, context);
|
|
1203
|
+
const result = await writeFile(scope, path, content);
|
|
1204
|
+
return {
|
|
1205
|
+
content: await afterWrite(action, result.rel, result.text),
|
|
1206
|
+
summary: result.summary,
|
|
1207
|
+
mutated: true,
|
|
1208
|
+
};
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
name: "edit_file",
|
|
1215
|
+
description:
|
|
1216
|
+
"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.",
|
|
1217
|
+
parameters: {
|
|
1218
|
+
type: "object",
|
|
1219
|
+
properties: {
|
|
1220
|
+
path: pathSchema("Project-relative file"),
|
|
1221
|
+
old: {
|
|
1222
|
+
type: "string",
|
|
1223
|
+
minLength: 1,
|
|
1224
|
+
description: "Exact text to replace",
|
|
1225
|
+
},
|
|
1226
|
+
new: { type: "string", description: "Replacement text" },
|
|
1227
|
+
},
|
|
1228
|
+
required: ["path", "old", "new"],
|
|
1229
|
+
additionalProperties: false,
|
|
1230
|
+
},
|
|
1231
|
+
plan(args, context) {
|
|
1232
|
+
const path = requiredPath(args);
|
|
1233
|
+
if (typeof args.old !== "string" || args.old.length === 0)
|
|
1234
|
+
throw new ToolArgumentError("old must be a non-empty string");
|
|
1235
|
+
if (typeof args.new !== "string")
|
|
1236
|
+
throw new ToolArgumentError("new must be a string");
|
|
1237
|
+
const { old: oldText, new: newText } = args;
|
|
1238
|
+
return {
|
|
1239
|
+
kind: "action",
|
|
1240
|
+
summary: `edit ${path}`,
|
|
1241
|
+
run: async (action) => {
|
|
1242
|
+
const scope = workspaceScope(action, context);
|
|
1243
|
+
const result = await editFile(scope, path, oldText, newText);
|
|
1244
|
+
return {
|
|
1245
|
+
content: await afterWrite(action, result.rel, result.text),
|
|
1246
|
+
summary: result.summary,
|
|
1247
|
+
mutated: true,
|
|
1248
|
+
};
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
{
|
|
1254
|
+
name: "web_search",
|
|
1255
|
+
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.`,
|
|
1256
|
+
parameters: {
|
|
1257
|
+
type: "object",
|
|
1258
|
+
properties: {
|
|
1259
|
+
query: { type: "string", maxLength: SEARCH_LIMITS.maxQueryChars },
|
|
1260
|
+
count: { type: "integer", minimum: 1, maximum: SEARCH_LIMITS.maxCount },
|
|
1261
|
+
},
|
|
1262
|
+
required: ["query"],
|
|
1263
|
+
additionalProperties: false,
|
|
1264
|
+
},
|
|
1265
|
+
plan(args) {
|
|
1266
|
+
if (typeof args.query !== "string" || args.query.trim().length === 0)
|
|
1267
|
+
throw new ToolArgumentError("query must be a non-empty string");
|
|
1268
|
+
if (args.query.length > SEARCH_LIMITS.maxQueryChars)
|
|
1269
|
+
throw new ToolArgumentError(
|
|
1270
|
+
`query must be at most ${SEARCH_LIMITS.maxQueryChars} characters`,
|
|
1271
|
+
);
|
|
1272
|
+
const query = args.query.trim();
|
|
1273
|
+
const count = optionalNumber(args, "count", {
|
|
1274
|
+
min: 1,
|
|
1275
|
+
max: SEARCH_LIMITS.maxCount,
|
|
1276
|
+
integer: true,
|
|
1277
|
+
});
|
|
1278
|
+
return {
|
|
1279
|
+
kind: "action",
|
|
1280
|
+
summary: `search ${query.slice(0, 60)}`,
|
|
1281
|
+
run: async (action) => {
|
|
1282
|
+
const web = action.web ?? {};
|
|
1283
|
+
const outcome = await webSearch(query, {
|
|
1284
|
+
...(count !== undefined ? { count } : {}),
|
|
1285
|
+
braveApiKey: web.braveApiKey ?? process.env.BRAVE_SEARCH_API_KEY,
|
|
1286
|
+
...(web.gatewayApiKey ? { gatewayApiKey: web.gatewayApiKey } : {}),
|
|
1287
|
+
...(web.gatewayBaseUrl
|
|
1288
|
+
? { gatewayBaseUrl: web.gatewayBaseUrl }
|
|
1289
|
+
: {}),
|
|
1290
|
+
searchTool: web.searchTool ?? process.env.DAWG_WEB_SEARCH,
|
|
1291
|
+
openRouterApiKey:
|
|
1292
|
+
web.openRouterApiKey ?? process.env.OPENROUTER_API_KEY,
|
|
1293
|
+
...(web.openRouterBaseUrl
|
|
1294
|
+
? { openRouterBaseUrl: web.openRouterBaseUrl }
|
|
1295
|
+
: {}),
|
|
1296
|
+
...(web.fetch ? { fetch: web.fetch } : {}),
|
|
1297
|
+
...(web.onSpend ? { onSpend: web.onSpend } : {}),
|
|
1298
|
+
...(action.signal ? { signal: action.signal } : {}),
|
|
1299
|
+
});
|
|
1300
|
+
const via = describeSearchProvider(outcome);
|
|
1301
|
+
return {
|
|
1302
|
+
content: formatSearchResults(outcome, query),
|
|
1303
|
+
summary: `searched via ${via}${outcome.fallbackFrom ? " (fallback)" : ""} · ${outcome.results.length} result${outcome.results.length === 1 ? "" : "s"}`,
|
|
1304
|
+
};
|
|
1305
|
+
},
|
|
1306
|
+
};
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
name: "fetch_url",
|
|
1311
|
+
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.`,
|
|
1312
|
+
parameters: {
|
|
1313
|
+
type: "object",
|
|
1314
|
+
properties: {
|
|
1315
|
+
url: { type: "string", maxLength: FETCH_LIMITS.maxUrlChars },
|
|
1316
|
+
},
|
|
1317
|
+
required: ["url"],
|
|
1318
|
+
additionalProperties: false,
|
|
1319
|
+
},
|
|
1320
|
+
plan(args) {
|
|
1321
|
+
if (typeof args.url !== "string" || args.url.trim().length === 0)
|
|
1322
|
+
throw new ToolArgumentError("url must be a non-empty string");
|
|
1323
|
+
if (args.url.length > FETCH_LIMITS.maxUrlChars)
|
|
1324
|
+
throw new ToolArgumentError(
|
|
1325
|
+
`url must be at most ${FETCH_LIMITS.maxUrlChars} characters`,
|
|
1326
|
+
);
|
|
1327
|
+
const url = args.url.trim();
|
|
1328
|
+
let host = url;
|
|
1329
|
+
try {
|
|
1330
|
+
host = new URL(url).hostname;
|
|
1331
|
+
} catch {
|
|
1332
|
+
// admitUrl reports the diagnostic when the action runs.
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
kind: "action",
|
|
1336
|
+
summary: `fetch ${host.slice(0, 60)}`,
|
|
1337
|
+
run: async (action) => {
|
|
1338
|
+
const page = await fetchUrl(url, {
|
|
1339
|
+
...(action.web?.fetch ? { fetch: action.web.fetch } : {}),
|
|
1340
|
+
...(action.web?.lookup ? { lookup: action.web.lookup } : {}),
|
|
1341
|
+
...(action.signal ? { signal: action.signal } : {}),
|
|
1342
|
+
});
|
|
1343
|
+
return {
|
|
1344
|
+
content: formatFetchedPage(page),
|
|
1345
|
+
summary: `fetched ${new URL(page.url).hostname} (${page.bytes} bytes${page.truncated ? ", truncated" : ""})`,
|
|
1346
|
+
};
|
|
1347
|
+
},
|
|
1348
|
+
};
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
...RHYTHM_TOOLS,
|
|
1352
|
+
...CHORD_TOOLS,
|
|
1353
|
+
...DRUM_TOOLS,
|
|
1354
|
+
...MEDIA_TOOLS,
|
|
1355
|
+
...PACK_TOOLS,
|
|
1356
|
+
// Looks tools up at call time, so it can plan any of the above.
|
|
1357
|
+
previewSoundTool((name) => findAgentTool(name)),
|
|
830
1358
|
] satisfies AgentTool[]);
|
|
831
1359
|
|
|
1360
|
+
/** Errors an `action` plan may raise that are safe to show to the model. */
|
|
1361
|
+
export function isActionDiagnostic(error: unknown): boolean {
|
|
1362
|
+
return (
|
|
1363
|
+
error instanceof WorkspaceError ||
|
|
1364
|
+
error instanceof WebError ||
|
|
1365
|
+
error instanceof ToolArgumentError ||
|
|
1366
|
+
error instanceof PackToolError ||
|
|
1367
|
+
error instanceof PreviewToolError ||
|
|
1368
|
+
error instanceof PackError
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const MAX_HOOK_CHARS = 4_000;
|
|
1373
|
+
|
|
1374
|
+
async function afterWrite(
|
|
1375
|
+
action: ActionContext,
|
|
1376
|
+
rel: string,
|
|
1377
|
+
text: string,
|
|
1378
|
+
): Promise<string> {
|
|
1379
|
+
if (!action.onWorkspaceWrite) return text;
|
|
1380
|
+
try {
|
|
1381
|
+
const note = await action.onWorkspaceWrite(rel);
|
|
1382
|
+
return typeof note === "string" && note.trim()
|
|
1383
|
+
? `${text}\n${note.trim().slice(0, MAX_HOOK_CHARS)}`
|
|
1384
|
+
: text;
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
const message = (
|
|
1387
|
+
error instanceof Error ? error.message : String(error)
|
|
1388
|
+
).slice(0, MAX_HOOK_CHARS);
|
|
1389
|
+
return `${text}\nafter write: ${message}`;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** The slug of the focused track: its name, or its id for a draft track. */
|
|
1394
|
+
export function focusedTrackSlug(
|
|
1395
|
+
context: Pick<ToolContext, "score" | "focusedTrackId">,
|
|
1396
|
+
): string {
|
|
1397
|
+
const track = context.score.tracks.find(
|
|
1398
|
+
(candidate) => candidate.id === context.focusedTrackId,
|
|
1399
|
+
);
|
|
1400
|
+
return trackSlug(track?.name ?? context.focusedTrackId);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function workspaceScope(
|
|
1404
|
+
action: ActionContext,
|
|
1405
|
+
context: ToolContext,
|
|
1406
|
+
): WorkspaceScope {
|
|
1407
|
+
if (!action.workspace)
|
|
1408
|
+
throw new WorkspaceError("file tools are unavailable in this session");
|
|
1409
|
+
return { root: action.workspace.root, trackSlug: focusedTrackSlug(context) };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function pathSchema(description: string) {
|
|
1413
|
+
return {
|
|
1414
|
+
type: "string",
|
|
1415
|
+
maxLength: WORKSPACE_LIMITS.maxPathChars,
|
|
1416
|
+
description,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function requiredPath(args: Record<string, unknown>): string {
|
|
1421
|
+
const path = optionalPath(args);
|
|
1422
|
+
if (path === undefined || path.length === 0)
|
|
1423
|
+
throw new ToolArgumentError("path is required");
|
|
1424
|
+
return path;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function optionalPath(args: Record<string, unknown>): string | undefined {
|
|
1428
|
+
if (args.path === undefined) return undefined;
|
|
1429
|
+
if (typeof args.path !== "string")
|
|
1430
|
+
throw new ToolArgumentError("path must be a string");
|
|
1431
|
+
if (args.path.length > WORKSPACE_LIMITS.maxPathChars)
|
|
1432
|
+
throw new ToolArgumentError(
|
|
1433
|
+
`path must be at most ${WORKSPACE_LIMITS.maxPathChars} characters`,
|
|
1434
|
+
);
|
|
1435
|
+
return args.path;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
832
1438
|
const TOOLS_BY_NAME = new Map(AGENT_TOOLS.map((tool) => [tool.name, tool]));
|
|
833
1439
|
|
|
834
1440
|
export function findAgentTool(
|
|
@@ -853,6 +1459,91 @@ export function chatTools(
|
|
|
853
1459
|
}));
|
|
854
1460
|
}
|
|
855
1461
|
|
|
1462
|
+
function fxToolCommand(
|
|
1463
|
+
effect: EffectName,
|
|
1464
|
+
args: Record<string, unknown>,
|
|
1465
|
+
): FxCommand {
|
|
1466
|
+
if (args.on === false) return { type: "fx-off", effect };
|
|
1467
|
+
if (args.preset !== undefined) {
|
|
1468
|
+
if (
|
|
1469
|
+
typeof args.preset !== "string" ||
|
|
1470
|
+
!Object.prototype.hasOwnProperty.call(
|
|
1471
|
+
FX_PRESETS[effect] ?? {},
|
|
1472
|
+
args.preset,
|
|
1473
|
+
)
|
|
1474
|
+
)
|
|
1475
|
+
throw new ToolArgumentError(
|
|
1476
|
+
`${effect} presets: ${Object.keys(FX_PRESETS[effect] ?? {}).join(", ") || "none"}`,
|
|
1477
|
+
);
|
|
1478
|
+
return { type: "fx-preset", effect, preset: args.preset };
|
|
1479
|
+
}
|
|
1480
|
+
if (args.params === undefined) {
|
|
1481
|
+
if (args.on === true) return { type: "fx-on", effect };
|
|
1482
|
+
throw new ToolArgumentError("set_fx needs on, preset, or params");
|
|
1483
|
+
}
|
|
1484
|
+
const params = record(args.params, "params");
|
|
1485
|
+
const values: Record<string, number | string | boolean> = {};
|
|
1486
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1487
|
+
const param = parseParamName(effect, name);
|
|
1488
|
+
if (!param)
|
|
1489
|
+
throw new ToolArgumentError(
|
|
1490
|
+
`${effect} has no parameter ${name}; it takes ${Object.keys(effectSpec(effect).params).join(", ")}`,
|
|
1491
|
+
);
|
|
1492
|
+
if (
|
|
1493
|
+
typeof value !== "number" &&
|
|
1494
|
+
typeof value !== "string" &&
|
|
1495
|
+
typeof value !== "boolean"
|
|
1496
|
+
)
|
|
1497
|
+
throw new ToolArgumentError(`${effect} ${name} must be a value`);
|
|
1498
|
+
values[param] = value;
|
|
1499
|
+
}
|
|
1500
|
+
return { type: "fx-set", effect, values };
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function synthToolCommand(args: Record<string, unknown>): SynthCommand {
|
|
1504
|
+
if (args.reset === true) return { type: "synth-reset" };
|
|
1505
|
+
if (args.zzfx !== undefined) {
|
|
1506
|
+
if (
|
|
1507
|
+
!Array.isArray(args.zzfx) ||
|
|
1508
|
+
args.zzfx.length > 21 ||
|
|
1509
|
+
!args.zzfx.every((n) => n === null || typeof n === "number")
|
|
1510
|
+
)
|
|
1511
|
+
throw new ToolArgumentError("zzfx must be up to 21 numbers or nulls");
|
|
1512
|
+
return { type: "synth-zzfx", values: args.zzfx as (number | null)[] };
|
|
1513
|
+
}
|
|
1514
|
+
if (args.preset !== undefined) {
|
|
1515
|
+
if (typeof args.preset !== "string" || !isSynthPreset(args.preset))
|
|
1516
|
+
throw new ToolArgumentError(
|
|
1517
|
+
`synth presets: ${Object.keys(SYNTH_PRESETS).join(", ")}`,
|
|
1518
|
+
);
|
|
1519
|
+
return { type: "synth-preset", preset: args.preset };
|
|
1520
|
+
}
|
|
1521
|
+
if (args.params === undefined)
|
|
1522
|
+
throw new ToolArgumentError("set_synth needs preset, reset, or params");
|
|
1523
|
+
const params = record(args.params, "params");
|
|
1524
|
+
const values: Record<
|
|
1525
|
+
string,
|
|
1526
|
+
number | string | boolean | readonly number[] | null
|
|
1527
|
+
> = {};
|
|
1528
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1529
|
+
const param = synthParamName(name);
|
|
1530
|
+
if (!param)
|
|
1531
|
+
throw new ToolArgumentError(
|
|
1532
|
+
`synth has no parameter ${name}; basics: ${SYNTH_SIMPLE.join(", ")} (DAWG.md lists all)`,
|
|
1533
|
+
);
|
|
1534
|
+
if (
|
|
1535
|
+
value !== null &&
|
|
1536
|
+
typeof value !== "number" &&
|
|
1537
|
+
typeof value !== "string" &&
|
|
1538
|
+
typeof value !== "boolean" &&
|
|
1539
|
+
!(Array.isArray(value) && value.every((n) => typeof n === "number"))
|
|
1540
|
+
)
|
|
1541
|
+
throw new ToolArgumentError(`synth ${name} must be a value`);
|
|
1542
|
+
values[param] = value as number;
|
|
1543
|
+
}
|
|
1544
|
+
return { type: "synth-set", values };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
856
1547
|
function targetTrack(args: Record<string, unknown>, context: ToolContext) {
|
|
857
1548
|
const trackId = args.trackId ?? context.focusedTrackId;
|
|
858
1549
|
if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
|