@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/agent/agent.ts
CHANGED
|
@@ -198,12 +198,14 @@ export const AGENT_SYSTEM_PROMPT = [
|
|
|
198
198
|
"Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
|
|
199
199
|
"Prefer a few well-formed calls (one add_notes call per track part) over many tiny ones.",
|
|
200
200
|
'For drums, create a track with instrument "kit" and prefer set_rhythm (Euclidean rows: pulses over steps, rotate, repeats for rolls, accent, probability, swing) so the beat stays editable as parameters; for a genre groove start from apply_drum_pattern (list_drum_patterns) and pick a sound with set_drum_kit; use add_drums only for one-off fills. Drum pitches select voices, so do not use add_notes for beats.',
|
|
201
|
+
"Song structure: list_sections shows named sections (bars count from 1); edit_section marks, adds, duplicates, moves, renames, deletes, mutes, varies (transpose/gain) and loops them; set_form sets the play order with repeats (intro verse chorus*2 outro); add_transition generates builds (riser, snare roll, filter sweep, uplifter), drops (cut plus impact) and drum fills at section boundaries.",
|
|
201
202
|
CHORD_PROCESS,
|
|
202
203
|
"Synth voices: set_synth shapes a synth track with Strudel params (ADSR, lpf/lpenv filter envelopes, fm/fmh, supersaw unison/detune, vib, penv, noise; z_* ZzFX sounds take slide/pitchJump/lfo/zcrush…) or a preset (pad lead pluck bass keys bell…).",
|
|
203
204
|
"Sampler voices: set_sample sets Strudel sample controls per voice (begin end speed unit loop loopBegin loopEnd clip/legato fit loopAt accelerate squiz cut); splice = slices with fit.",
|
|
204
205
|
"Effects (set_fx; set_effects and set_automation): the brief's effects list is the fixed chain order with each effect's simple params. set_fx turns an effect on with good defaults, loads a preset or sets params by dawg or Strudel name. Lanes: volume, pan, filter, resonance, delay-feedback, delay-mix, wt and <effect>-<param> (e.g. autofilter-cutoff). Sidechain: put pads/bass on `orbit` 2 and give the kick `duck` (orbit 2, preset pump); a ducker never ducks itself.",
|
|
205
206
|
"Previewing: preview_sound renders a track, or candidate sound tool calls (changes: [{tool, args}]), without committing, returns loudness, brightness and a comparison, and plays it once in the user's window. Use it when choosing between sounds (tables, presets, effect amounts), say how it sounds, then commit with the normal tools.",
|
|
206
207
|
"Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
|
|
208
|
+
"Mastering: set_master sets the song master and loudness target; measure_mix reports LUFS, true peak, balance and correlation. Master only when asked for loudness or a finished sound, and measure before and after.",
|
|
207
209
|
"If a call is rejected, read the diagnostic and either fix the arguments or stop.",
|
|
208
210
|
WORKSPACE_PROMPT,
|
|
209
211
|
MEDIA_PROMPT,
|
package/src/agent/brief.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { describeMaster } from "../../core/master.ts";
|
|
1
2
|
import type { TrackScore } from "../../core/score.ts";
|
|
3
|
+
import {
|
|
4
|
+
describeSongTime,
|
|
5
|
+
loopTicksOf,
|
|
6
|
+
meterSegments,
|
|
7
|
+
} from "../../core/tempo.ts";
|
|
2
8
|
import {
|
|
3
9
|
AVAILABLE_EFFECTS,
|
|
4
10
|
AVAILABLE_FX_PRESETS,
|
|
5
11
|
AVAILABLE_INSTRUMENTS,
|
|
6
12
|
} from "../audio/wav.ts";
|
|
7
13
|
import type { ProjectOutline } from "./workspace.ts";
|
|
14
|
+
import {
|
|
15
|
+
describeTuning,
|
|
16
|
+
resolveTuning,
|
|
17
|
+
type Tuning,
|
|
18
|
+
} from "../../core/tuning.ts";
|
|
8
19
|
|
|
9
20
|
export const MAX_BRIEF_BYTES = 12 * 1024;
|
|
10
21
|
const MAX_FOCUSED_NOTES = 96;
|
|
@@ -93,12 +104,35 @@ export function compositionBrief(options: {
|
|
|
93
104
|
...((track.delayMixAutomation?.length ?? 0) > 0
|
|
94
105
|
? { delayMixAutomation: track.delayMixAutomation!.length }
|
|
95
106
|
: {}),
|
|
107
|
+
...(track.time
|
|
108
|
+
? {
|
|
109
|
+
// Per-track time in beats: rate, phase offset and loop cycle.
|
|
110
|
+
time: {
|
|
111
|
+
...(track.time.rate !== undefined
|
|
112
|
+
? { rate: track.time.rate }
|
|
113
|
+
: {}),
|
|
114
|
+
...(track.time.phase !== undefined
|
|
115
|
+
? { phaseBeats: beats(track.time.phase) }
|
|
116
|
+
: {}),
|
|
117
|
+
...(track.time.cycle !== undefined
|
|
118
|
+
? { cycleBeats: beats(track.time.cycle) }
|
|
119
|
+
: {}),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
...(track.tuning
|
|
124
|
+
? { tuning: tuningBrief(score.tuning, track.tuning, score.key) }
|
|
125
|
+
: {}),
|
|
96
126
|
...(track.wavetable ? { wavetable: track.wavetable } : {}),
|
|
97
127
|
...((track.wtAutomation?.length ?? 0) > 0
|
|
98
128
|
? { wtAutomation: track.wtAutomation!.length }
|
|
99
129
|
: {}),
|
|
100
130
|
};
|
|
101
131
|
});
|
|
132
|
+
const tunedNotes = score.notes.some(
|
|
133
|
+
(note) =>
|
|
134
|
+
note.trackId === options.focusedTrackId && note.cents !== undefined,
|
|
135
|
+
);
|
|
102
136
|
const focusedNotes = score.notes
|
|
103
137
|
.filter((note) => note.trackId === options.focusedTrackId)
|
|
104
138
|
.slice()
|
|
@@ -114,11 +148,14 @@ export function compositionBrief(options: {
|
|
|
114
148
|
beats(note.startTick),
|
|
115
149
|
beats(note.durationTicks),
|
|
116
150
|
Math.round(note.velocity * 100) / 100,
|
|
151
|
+
...(tunedNotes ? [note.cents ?? 0] : []),
|
|
117
152
|
]);
|
|
118
153
|
const recent = (options.recentOperations ?? [])
|
|
119
154
|
.slice(-MAX_RECENT)
|
|
120
155
|
.map((line) => line.replace(/\s+/g, " ").slice(0, MAX_RECENT_CHARS));
|
|
121
156
|
const project = options.project;
|
|
157
|
+
const opening = meterSegments(score)[0]!;
|
|
158
|
+
const openingMeter = `${opening.beatsPerBar}/${opening.beatUnit}`;
|
|
122
159
|
const build = (
|
|
123
160
|
noteLimit: number,
|
|
124
161
|
trackLimit: number,
|
|
@@ -129,17 +166,30 @@ export function compositionBrief(options: {
|
|
|
129
166
|
return JSON.stringify({
|
|
130
167
|
revision: options.revision,
|
|
131
168
|
tempoBpm: score.tempoBpm,
|
|
132
|
-
meter:
|
|
169
|
+
meter: openingMeter,
|
|
133
170
|
bars: score.bars,
|
|
134
|
-
loopBeats: score
|
|
171
|
+
loopBeats: beats(loopTicksOf(score)),
|
|
172
|
+
// Tempo events (=step, →ramp; bpm@beat), meter changes and fermatas.
|
|
173
|
+
...(score.time ? { time: describeSongTime(score) } : {}),
|
|
135
174
|
...(score.key ? { key: score.key } : {}),
|
|
175
|
+
...(score.tuning
|
|
176
|
+
? { tuning: tuningBrief(score.tuning, undefined, score.key) }
|
|
177
|
+
: {}),
|
|
178
|
+
...(score.master ? { master: describeMaster(score.master) } : {}),
|
|
136
179
|
focusedTrack: options.focusedTrackId,
|
|
137
180
|
tracks: tracks.slice(0, trackLimit),
|
|
138
181
|
...(tracks.length > trackLimit
|
|
139
182
|
? { omittedTracks: tracks.length - trackLimit }
|
|
140
183
|
: {}),
|
|
141
184
|
focusedNotes: {
|
|
142
|
-
columns: [
|
|
185
|
+
columns: [
|
|
186
|
+
"id",
|
|
187
|
+
"pitch",
|
|
188
|
+
"startBeat",
|
|
189
|
+
"durationBeats",
|
|
190
|
+
"velocity",
|
|
191
|
+
...(tunedNotes ? ["cents"] : []),
|
|
192
|
+
],
|
|
143
193
|
rows: visible,
|
|
144
194
|
...(focusedNotes.length > visible.length
|
|
145
195
|
? { omitted: focusedNotes.length - visible.length }
|
|
@@ -183,3 +233,27 @@ export function compositionBrief(options: {
|
|
|
183
233
|
export function noteName(midi: number): string {
|
|
184
234
|
return `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
185
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A tuning as the agent reads it: the summary, plus the steps per period and
|
|
239
|
+
* whether keys map one per step (`linear`), in which case a key name such as
|
|
240
|
+
* C5 no longer sounds as its 12-TET pitch.
|
|
241
|
+
*/
|
|
242
|
+
function tuningBrief(
|
|
243
|
+
song: Tuning | undefined,
|
|
244
|
+
track: Tuning | undefined,
|
|
245
|
+
key: string | null | undefined,
|
|
246
|
+
): { summary: string; steps?: number; linear?: boolean } {
|
|
247
|
+
const summary = describeTuning(track ?? song);
|
|
248
|
+
try {
|
|
249
|
+
const table = resolveTuning(song, track, key);
|
|
250
|
+
if (!table) return { summary };
|
|
251
|
+
return {
|
|
252
|
+
summary,
|
|
253
|
+
steps: table.size,
|
|
254
|
+
...(table.linear && table.size !== 12 ? { linear: true } : {}),
|
|
255
|
+
};
|
|
256
|
+
} catch {
|
|
257
|
+
return { summary };
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/agent/chord-tools.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type Spread,
|
|
25
25
|
} from "../../core/chords.ts";
|
|
26
26
|
import { midiToPitch } from "../../core/pitch.ts";
|
|
27
|
+
import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
|
|
27
28
|
import { SCORE_LIMITS, type ScoreOperation } from "../../core/score.ts";
|
|
28
29
|
import type { AgentTool, ToolContext } from "./tools.ts";
|
|
29
30
|
|
|
@@ -266,6 +267,12 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
266
267
|
`${total} notes exceed ${MAX_CHORD_NOTES}; use fewer chords, a slower rate or fewer octaves`,
|
|
267
268
|
);
|
|
268
269
|
const tpb = context.score.ticksPerBeat;
|
|
270
|
+
const tuningOf = (id: string) =>
|
|
271
|
+
resolveTuning(
|
|
272
|
+
context.score.tuning,
|
|
273
|
+
context.score.tracks.find((track) => track.id === id)?.tuning,
|
|
274
|
+
context.score.key,
|
|
275
|
+
);
|
|
269
276
|
const operations: ScoreOperation[] = [];
|
|
270
277
|
const add = (
|
|
271
278
|
notes: readonly {
|
|
@@ -276,6 +283,7 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
276
283
|
}[],
|
|
277
284
|
target: string,
|
|
278
285
|
) => {
|
|
286
|
+
const table = tuningOf(target);
|
|
279
287
|
for (const note of notes)
|
|
280
288
|
operations.push({
|
|
281
289
|
type: "addNote",
|
|
@@ -284,7 +292,7 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
284
292
|
trackId: target,
|
|
285
293
|
startTick: Math.round(note.start * tpb),
|
|
286
294
|
durationTicks: Math.max(1, Math.round(note.length * tpb)),
|
|
287
|
-
pitch: note.pitch,
|
|
295
|
+
pitch: snapToTuning(note.pitch, table),
|
|
288
296
|
velocity: note.velocity,
|
|
289
297
|
},
|
|
290
298
|
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools for expression: `set_expression` (articulation, glide, bend
|
|
3
|
+
* and vibrato over a note range) and `set_performance` (a track's glide
|
|
4
|
+
* mode, sustain pedal, velocity curve and seeded humanize). Both validate
|
|
5
|
+
* through core/expression.ts, the same rules the score loader applies.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
ARTICULATIONS,
|
|
9
|
+
ExpressionValidationError,
|
|
10
|
+
GLIDE_MODES,
|
|
11
|
+
PEDAL_STATES,
|
|
12
|
+
VELOCITY_CURVES,
|
|
13
|
+
normalizeArticulation,
|
|
14
|
+
normalizeBend,
|
|
15
|
+
normalizeHumanize,
|
|
16
|
+
normalizeNoteGlide,
|
|
17
|
+
normalizePedal,
|
|
18
|
+
normalizeTrackGlide,
|
|
19
|
+
normalizeVelocityCurve,
|
|
20
|
+
normalizeVibrato,
|
|
21
|
+
normalizeNoteHumanize,
|
|
22
|
+
type NoteExpressionPatch,
|
|
23
|
+
} from "../../core/expression.ts";
|
|
24
|
+
import {
|
|
25
|
+
SCORE_LIMITS,
|
|
26
|
+
type ScoreOperation,
|
|
27
|
+
type TrackPatch,
|
|
28
|
+
} from "../../core/score.ts";
|
|
29
|
+
import { loopTicksOf } from "../../core/tempo.ts";
|
|
30
|
+
import { BEND_SHAPES, barPedal } from "../commands/expression.ts";
|
|
31
|
+
import type { AgentTool, ToolContext } from "./tools.ts";
|
|
32
|
+
|
|
33
|
+
const MAX_TARGET_NOTES = 512;
|
|
34
|
+
|
|
35
|
+
class ExpressionToolError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "ToolArgumentError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validated<T>(read: () => T): T {
|
|
43
|
+
try {
|
|
44
|
+
return read();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof ExpressionValidationError)
|
|
47
|
+
throw new ExpressionToolError(error.message);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function trackOf(args: Record<string, unknown>, context: ToolContext): string {
|
|
53
|
+
const trackId = args.trackId ?? context.focusedTrackId;
|
|
54
|
+
if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
|
|
55
|
+
throw new ExpressionToolError("trackId must be a short string");
|
|
56
|
+
if (!context.score.tracks.some((track) => track.id === trackId))
|
|
57
|
+
throw new ExpressionToolError(`unknown trackId ${trackId.slice(0, 64)}`);
|
|
58
|
+
return trackId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function beat(args: Record<string, unknown>, key: string): number | undefined {
|
|
62
|
+
const value = args[key];
|
|
63
|
+
if (value === undefined) return undefined;
|
|
64
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
65
|
+
throw new ExpressionToolError(`${key} must be a number >= 0`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Note ids `args` selects: `noteIds`, else the track's notes in a beat range. */
|
|
70
|
+
function selectNotes(
|
|
71
|
+
args: Record<string, unknown>,
|
|
72
|
+
context: ToolContext,
|
|
73
|
+
): { trackId: string; noteIds: string[] } {
|
|
74
|
+
const trackId = trackOf(args, context);
|
|
75
|
+
if (args.noteIds !== undefined) {
|
|
76
|
+
if (
|
|
77
|
+
!Array.isArray(args.noteIds) ||
|
|
78
|
+
args.noteIds.length === 0 ||
|
|
79
|
+
args.noteIds.length > MAX_TARGET_NOTES
|
|
80
|
+
)
|
|
81
|
+
throw new ExpressionToolError(
|
|
82
|
+
`noteIds must be an array of 1..${MAX_TARGET_NOTES} ids`,
|
|
83
|
+
);
|
|
84
|
+
const known = new Set(context.score.notes.map((note) => note.id));
|
|
85
|
+
const ids = [...new Set(args.noteIds as unknown[])].map((id, index) => {
|
|
86
|
+
if (typeof id !== "string" || !known.has(id))
|
|
87
|
+
throw new ExpressionToolError(`noteIds[${index}] is not a note id`);
|
|
88
|
+
return id;
|
|
89
|
+
});
|
|
90
|
+
return { trackId, noteIds: ids };
|
|
91
|
+
}
|
|
92
|
+
const tpb = context.score.ticksPerBeat;
|
|
93
|
+
const from = beat(args, "fromBeat") ?? 0;
|
|
94
|
+
const to = beat(args, "toBeat") ?? Infinity;
|
|
95
|
+
if (!(to > from))
|
|
96
|
+
throw new ExpressionToolError("toBeat must be after fromBeat");
|
|
97
|
+
const noteIds = context.score.notes
|
|
98
|
+
.filter(
|
|
99
|
+
(note) =>
|
|
100
|
+
note.trackId === trackId &&
|
|
101
|
+
note.startTick >= Math.round(from * tpb) &&
|
|
102
|
+
note.startTick < to * tpb,
|
|
103
|
+
)
|
|
104
|
+
.map((note) => note.id);
|
|
105
|
+
if (noteIds.length === 0)
|
|
106
|
+
throw new ExpressionToolError(
|
|
107
|
+
`no notes on ${trackId} start in beats ${from}..${to === Infinity ? "end" : to}`,
|
|
108
|
+
);
|
|
109
|
+
if (noteIds.length > MAX_TARGET_NOTES)
|
|
110
|
+
throw new ExpressionToolError(
|
|
111
|
+
`${noteIds.length} notes match; narrow the range to ${MAX_TARGET_NOTES}`,
|
|
112
|
+
);
|
|
113
|
+
return { trackId, noteIds };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const noteRange = {
|
|
117
|
+
trackId: { type: "string" },
|
|
118
|
+
noteIds: { type: "array", items: { type: "string" } },
|
|
119
|
+
fromBeat: { type: "number" },
|
|
120
|
+
toBeat: { type: "number" },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const bendPoint = {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: { at: { type: "number" }, cents: { type: "number" } },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const EXPRESSION_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
129
|
+
{
|
|
130
|
+
name: "set_expression",
|
|
131
|
+
description: `Per-note expression on noteIds, or on trackId's notes starting in [fromBeat, toBeat); null clears. articulation: staccato halves length, legato holds to the next note, accent/marcato louder, ghost quiet. glide: seconds sliding in from the previous pitch. bend: [{at 0..1, cents}] or ${Object.keys(BEND_SHAPES).join("|")}. vibrato: {rate Hz, depth cents, delay s}. humanize: {timing ms, velocity %, length %} for just these notes, replacing the track's amounts ({} keeps them exact). Overrides the synth's slide, penv, vib. On a legato-glide track a note glides when it overlaps the previous one (use articulation legato), or, with its own glide, when the previous note ends at most a 16th step earlier (a TB-303 slide; a longer rest never slides).`,
|
|
132
|
+
parameters: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
...noteRange,
|
|
136
|
+
articulation: {
|
|
137
|
+
type: ["string", "null"],
|
|
138
|
+
enum: [...ARTICULATIONS, null],
|
|
139
|
+
},
|
|
140
|
+
glide: { type: ["number", "null"] },
|
|
141
|
+
bend: { type: ["array", "string", "null"], items: bendPoint },
|
|
142
|
+
vibrato: {
|
|
143
|
+
type: ["object", "null"],
|
|
144
|
+
properties: {
|
|
145
|
+
rate: { type: "number" },
|
|
146
|
+
depth: { type: "number" },
|
|
147
|
+
delay: { type: "number" },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
humanize: {
|
|
151
|
+
type: ["object", "null"],
|
|
152
|
+
properties: {
|
|
153
|
+
timing: { type: "number" },
|
|
154
|
+
velocity: { type: "number" },
|
|
155
|
+
length: { type: "number" },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
plan(args, context) {
|
|
162
|
+
const patch: Record<string, unknown> = {};
|
|
163
|
+
if (args.articulation !== undefined)
|
|
164
|
+
patch.articulation =
|
|
165
|
+
args.articulation === null
|
|
166
|
+
? null
|
|
167
|
+
: validated(() => normalizeArticulation(args.articulation));
|
|
168
|
+
if (args.glide !== undefined)
|
|
169
|
+
patch.glide =
|
|
170
|
+
args.glide === null
|
|
171
|
+
? null
|
|
172
|
+
: validated(() => normalizeNoteGlide(args.glide));
|
|
173
|
+
if (args.bend !== undefined)
|
|
174
|
+
patch.bend =
|
|
175
|
+
args.bend === null
|
|
176
|
+
? null
|
|
177
|
+
: typeof args.bend === "string"
|
|
178
|
+
? (BEND_SHAPES[args.bend] ??
|
|
179
|
+
(() => {
|
|
180
|
+
throw new ExpressionToolError(
|
|
181
|
+
`bend shape must be one of ${Object.keys(BEND_SHAPES).join(", ")}`,
|
|
182
|
+
);
|
|
183
|
+
})())
|
|
184
|
+
: (validated(() => normalizeBend(args.bend)) ?? null);
|
|
185
|
+
if (args.vibrato !== undefined)
|
|
186
|
+
patch.vibrato =
|
|
187
|
+
args.vibrato === null
|
|
188
|
+
? null
|
|
189
|
+
: validated(() => normalizeVibrato(args.vibrato));
|
|
190
|
+
if (args.humanize !== undefined)
|
|
191
|
+
patch.humanize =
|
|
192
|
+
args.humanize === null
|
|
193
|
+
? null
|
|
194
|
+
: validated(() => normalizeNoteHumanize(args.humanize));
|
|
195
|
+
if (Object.keys(patch).length === 0)
|
|
196
|
+
throw new ExpressionToolError(
|
|
197
|
+
"set at least one of articulation, glide, bend, vibrato, humanize",
|
|
198
|
+
);
|
|
199
|
+
const { trackId, noteIds } = selectNotes(args, context);
|
|
200
|
+
const operations: ScoreOperation[] = noteIds.map((noteId) => ({
|
|
201
|
+
type: "updateNote",
|
|
202
|
+
noteId,
|
|
203
|
+
patch: patch as NoteExpressionPatch,
|
|
204
|
+
}));
|
|
205
|
+
const fields = Object.entries(patch)
|
|
206
|
+
.map(([key, value]) =>
|
|
207
|
+
value === null
|
|
208
|
+
? `${key} off`
|
|
209
|
+
: typeof value === "string" || typeof value === "number"
|
|
210
|
+
? `${key} ${value}`
|
|
211
|
+
: key,
|
|
212
|
+
)
|
|
213
|
+
.join(", ");
|
|
214
|
+
return {
|
|
215
|
+
kind: "score",
|
|
216
|
+
operations,
|
|
217
|
+
trackId,
|
|
218
|
+
summary: `${fields} · ${noteIds.length} note${noteIds.length === 1 ? "" : "s"}`,
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "set_performance",
|
|
224
|
+
description:
|
|
225
|
+
'A track\'s performance; null clears. glide: {time s, mode legato|mono|poly}: legato slides only between overlapping notes without retriggering (TB-303), mono always slides, poly slides every voice. pedal: [{beat, state down|half|up}] or "bars" to re-pedal each bar. velocityCurve: {curve linear|soft|hard|fixed, fixed 0..1}. humanize: {timing ms, velocity %, length %, seed} applied at render; keep the seed for the same take.',
|
|
226
|
+
parameters: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
trackId: { type: "string" },
|
|
230
|
+
glide: {
|
|
231
|
+
type: ["object", "null"],
|
|
232
|
+
properties: {
|
|
233
|
+
time: { type: "number" },
|
|
234
|
+
mode: { type: "string", enum: GLIDE_MODES },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
pedal: {
|
|
238
|
+
type: ["array", "string", "null"],
|
|
239
|
+
items: {
|
|
240
|
+
type: "object",
|
|
241
|
+
properties: {
|
|
242
|
+
beat: { type: "number" },
|
|
243
|
+
state: { type: "string", enum: PEDAL_STATES },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
velocityCurve: {
|
|
248
|
+
type: ["object", "null"],
|
|
249
|
+
properties: {
|
|
250
|
+
curve: { type: "string", enum: VELOCITY_CURVES },
|
|
251
|
+
fixed: { type: "number" },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
humanize: {
|
|
255
|
+
type: ["object", "null"],
|
|
256
|
+
properties: {
|
|
257
|
+
timing: { type: "number" },
|
|
258
|
+
velocity: { type: "number" },
|
|
259
|
+
length: { type: "number" },
|
|
260
|
+
seed: { type: "integer" },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
plan(args, context) {
|
|
267
|
+
const trackId = trackOf(args, context);
|
|
268
|
+
const track = context.score.tracks.find((item) => item.id === trackId)!;
|
|
269
|
+
const score = context.score;
|
|
270
|
+
const patch: Record<string, unknown> = {};
|
|
271
|
+
if (args.glide !== undefined)
|
|
272
|
+
patch.glide =
|
|
273
|
+
args.glide === null
|
|
274
|
+
? null
|
|
275
|
+
: validated(() =>
|
|
276
|
+
normalizeTrackGlide({
|
|
277
|
+
...(track.glide ?? {}),
|
|
278
|
+
...(args.glide as Record<string, unknown>),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
if (args.pedal !== undefined) {
|
|
282
|
+
const maxTick = loopTicksOf(score);
|
|
283
|
+
if (args.pedal === null) patch.pedal = null;
|
|
284
|
+
else if (args.pedal === "bars") patch.pedal = barPedal(score);
|
|
285
|
+
else {
|
|
286
|
+
if (!Array.isArray(args.pedal))
|
|
287
|
+
throw new ExpressionToolError(
|
|
288
|
+
'pedal must be an array, "bars" or null',
|
|
289
|
+
);
|
|
290
|
+
const events = args.pedal.map((value: unknown, index) => {
|
|
291
|
+
const event = value as Record<string, unknown>;
|
|
292
|
+
const at = beat(event ?? {}, "beat");
|
|
293
|
+
if (at === undefined)
|
|
294
|
+
throw new ExpressionToolError(`pedal[${index}].beat is required`);
|
|
295
|
+
return {
|
|
296
|
+
tick: Math.round(at * score.ticksPerBeat),
|
|
297
|
+
state: event.state,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
patch.pedal =
|
|
301
|
+
validated(() => normalizePedal(events, maxTick)) ?? null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (args.velocityCurve !== undefined)
|
|
305
|
+
patch.velocityCurve =
|
|
306
|
+
args.velocityCurve === null
|
|
307
|
+
? null
|
|
308
|
+
: (validated(() => normalizeVelocityCurve(args.velocityCurve)) ??
|
|
309
|
+
null);
|
|
310
|
+
if (args.humanize !== undefined)
|
|
311
|
+
patch.humanize =
|
|
312
|
+
args.humanize === null
|
|
313
|
+
? null
|
|
314
|
+
: (validated(() =>
|
|
315
|
+
normalizeHumanize({
|
|
316
|
+
...(track.humanize ?? { seed: 1 }),
|
|
317
|
+
...(args.humanize as Record<string, unknown>),
|
|
318
|
+
}),
|
|
319
|
+
) ?? null);
|
|
320
|
+
if (Object.keys(patch).length === 0)
|
|
321
|
+
throw new ExpressionToolError(
|
|
322
|
+
"set at least one of glide, pedal, velocityCurve, humanize",
|
|
323
|
+
);
|
|
324
|
+
return {
|
|
325
|
+
kind: "score",
|
|
326
|
+
operations: [
|
|
327
|
+
{ type: "updateTrack", trackId, patch: patch as TrackPatch },
|
|
328
|
+
],
|
|
329
|
+
trackId,
|
|
330
|
+
summary: `${trackId} ${Object.entries(patch)
|
|
331
|
+
.map(([key, value]) => (value === null ? `${key} off` : key))
|
|
332
|
+
.join(", ")}`,
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
] satisfies AgentTool[]);
|