@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -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,
@@ -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: `${score.beatsPerBar}/4`,
169
+ meter: openingMeter,
133
170
  bars: score.bars,
134
- loopBeats: score.bars * score.beatsPerBar,
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: ["id", "pitch", "startBeat", "durationBeats", "velocity"],
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
+ }
@@ -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[]);