@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
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Agent tools for song structure (0.5): `list_sections`, `edit_section`,
3
+ * `set_form` and `add_transition`. They build the same commands the prompt
4
+ * runs (src/commands/arrange.ts), so a tool call and `section …` / `form …`
5
+ * / `build …` agree, and their operations are the score diff.
6
+ *
7
+ * Bars here count from 1 like the prompt and the bar ruler; the score and
8
+ * the SDK store 0-based `startBar`.
9
+ */
10
+ import { diffScores } from "../../core/diff.ts";
11
+ import { SCORE_LIMITS } from "../../core/score.ts";
12
+ import {
13
+ arrangedBars,
14
+ DROP_CUT_MIN,
15
+ FILL_BEATS_MIN,
16
+ FILL_STYLES,
17
+ formatForm,
18
+ type FillStyle,
19
+ } from "../../core/sections.ts";
20
+ import {
21
+ applySectionCommand,
22
+ describeArrangement,
23
+ type SectionCommand,
24
+ } from "../commands/arrange.ts";
25
+ import type { AgentTool, ToolContext, ToolPlan } from "./tools.ts";
26
+ import { ToolArgumentError } from "./tools.ts";
27
+
28
+ const SECTION_ACTIONS = [
29
+ "mark",
30
+ "add",
31
+ "duplicate",
32
+ "move",
33
+ "rename",
34
+ "delete",
35
+ "unmark",
36
+ "mute",
37
+ "unmute",
38
+ "vary",
39
+ "reset",
40
+ "loop",
41
+ "unloop",
42
+ ] as const;
43
+
44
+ type SectionAction = (typeof SECTION_ACTIONS)[number];
45
+
46
+ const name = {
47
+ type: "string",
48
+ minLength: 1,
49
+ maxLength: 32,
50
+ description: "Section name: intro, verse, pre, chorus, build, drop, …",
51
+ } as const;
52
+
53
+ const bar = (description: string) =>
54
+ ({
55
+ type: "integer",
56
+ minimum: 1,
57
+ maximum: SCORE_LIMITS.maxBars,
58
+ description,
59
+ }) as const;
60
+
61
+ function text(value: unknown, field: string): string {
62
+ if (typeof value !== "string" || value.trim() === "")
63
+ throw new ToolArgumentError(`${field} must be a non-empty string`);
64
+ return value.trim();
65
+ }
66
+
67
+ function count(value: unknown, field: string, max: number): number {
68
+ if (
69
+ typeof value !== "number" ||
70
+ !Number.isInteger(value) ||
71
+ value < 1 ||
72
+ value > max
73
+ )
74
+ throw new ToolArgumentError(`${field} must be an integer 1..${max}`);
75
+ return value;
76
+ }
77
+
78
+ function optionalBar(value: unknown, field: string): number | undefined {
79
+ return value === undefined
80
+ ? undefined
81
+ : count(value, field, SCORE_LIMITS.maxBars) - 1;
82
+ }
83
+
84
+ function flag(value: unknown, field: string): boolean | undefined {
85
+ if (value === undefined) return undefined;
86
+ if (typeof value !== "boolean")
87
+ throw new ToolArgumentError(`${field} must be true or false`);
88
+ return value;
89
+ }
90
+
91
+ /** Apply a prompt command and turn its result into a score plan. */
92
+ function plan(
93
+ context: ToolContext,
94
+ command: SectionCommand,
95
+ ): ToolPlan & { kind: "score" } {
96
+ const result = applySectionCommand(
97
+ context.score,
98
+ context.focusedTrackId,
99
+ command,
100
+ );
101
+ if (!result.ok) throw new ToolArgumentError(result.message);
102
+ return {
103
+ kind: "score",
104
+ operations: result.next ? diffScores(context.score, result.next) : [],
105
+ trackId: context.focusedTrackId,
106
+ summary: result.message,
107
+ };
108
+ }
109
+
110
+ function sectionCommand(args: Record<string, unknown>): SectionCommand {
111
+ const action = args.action as SectionAction;
112
+ if (!SECTION_ACTIONS.includes(action))
113
+ throw new ToolArgumentError(`action must be ${SECTION_ACTIONS.join(", ")}`);
114
+ if (action === "unloop") return { type: "section-loop" };
115
+ if (action === "add") {
116
+ const command: { type: "section-add"; name?: string; bars?: number } = {
117
+ type: "section-add",
118
+ };
119
+ if (args.name !== undefined) command.name = text(args.name, "name");
120
+ if (args.bars !== undefined)
121
+ command.bars = count(args.bars, "bars", SCORE_LIMITS.maxBars);
122
+ return command;
123
+ }
124
+ const section = text(args.name, "name");
125
+ switch (action) {
126
+ case "mark": {
127
+ const startBar = optionalBar(args.fromBar, "fromBar");
128
+ if (startBar === undefined || args.bars === undefined)
129
+ throw new ToolArgumentError("mark needs fromBar and bars");
130
+ return {
131
+ type: "section-mark",
132
+ name: section,
133
+ startBar,
134
+ bars: count(args.bars, "bars", SCORE_LIMITS.maxBars),
135
+ };
136
+ }
137
+ case "duplicate":
138
+ return args.to === undefined
139
+ ? { type: "section-dup", name: section }
140
+ : { type: "section-dup", name: section, as: text(args.to, "to") };
141
+ case "move": {
142
+ if (args.toBar !== undefined)
143
+ return {
144
+ type: "section-move",
145
+ name: section,
146
+ to: { bar: optionalBar(args.toBar, "toBar")! },
147
+ };
148
+ if (args.before !== undefined)
149
+ return {
150
+ type: "section-move",
151
+ name: section,
152
+ to: { before: text(args.before, "before") },
153
+ };
154
+ if (args.after !== undefined)
155
+ return {
156
+ type: "section-move",
157
+ name: section,
158
+ to: { after: text(args.after, "after") },
159
+ };
160
+ throw new ToolArgumentError("move needs toBar, before or after");
161
+ }
162
+ case "rename":
163
+ return { type: "section-rename", name: section, to: text(args.to, "to") };
164
+ case "delete":
165
+ return { type: "section-delete", name: section };
166
+ case "unmark":
167
+ return { type: "section-unmark", name: section };
168
+ case "mute":
169
+ case "unmute": {
170
+ const tracks = Array.isArray(args.tracks) ? args.tracks : undefined;
171
+ if (
172
+ !tracks ||
173
+ tracks.length === 0 ||
174
+ tracks.some((id) => typeof id !== "string")
175
+ )
176
+ throw new ToolArgumentError(`${action} needs tracks: [track ids]`);
177
+ return {
178
+ type: "section-mute",
179
+ name: section,
180
+ tracks: tracks as string[],
181
+ muted: action === "mute",
182
+ };
183
+ }
184
+ case "vary": {
185
+ const command: {
186
+ type: "section-vary";
187
+ name: string;
188
+ track?: string;
189
+ transpose?: number;
190
+ gain?: number;
191
+ off?: boolean;
192
+ } = { type: "section-vary", name: section };
193
+ if (args.trackId !== undefined)
194
+ command.track = text(args.trackId, "trackId");
195
+ if (args.transpose !== undefined) {
196
+ if (
197
+ typeof args.transpose !== "number" ||
198
+ !Number.isInteger(args.transpose)
199
+ )
200
+ throw new ToolArgumentError("transpose must be whole semitones");
201
+ command.transpose = args.transpose;
202
+ }
203
+ if (args.gain !== undefined) {
204
+ if (typeof args.gain !== "number")
205
+ throw new ToolArgumentError("gain must be a number 0..2");
206
+ command.gain = args.gain;
207
+ }
208
+ if (args.transpose === undefined && args.gain === undefined)
209
+ command.off = true;
210
+ return command;
211
+ }
212
+ case "reset":
213
+ return { type: "section-reset", name: section };
214
+ case "loop":
215
+ return { type: "section-loop", name: section };
216
+ }
217
+ throw new ToolArgumentError(`unknown action ${String(action)}`);
218
+ }
219
+
220
+ function transitionCommand(args: Record<string, unknown>): SectionCommand {
221
+ const section =
222
+ args.section === undefined ? undefined : text(args.section, "section");
223
+ const at = optionalBar(args.atBar, "atBar");
224
+ if (args.type === "build") {
225
+ const options: Record<string, unknown> = {};
226
+ if (section !== undefined) options.section = section;
227
+ if (args.into !== undefined) options.into = text(args.into, "into");
228
+ if (at !== undefined) options.startBar = at;
229
+ if (args.bars !== undefined)
230
+ options.bars = count(args.bars, "bars", SCORE_LIMITS.maxBars);
231
+ for (const layer of ["riser", "roll", "sweep", "uplifter"] as const) {
232
+ const value = flag(args[layer], layer);
233
+ if (value !== undefined) options[layer] = value;
234
+ }
235
+ return { type: "build", options };
236
+ }
237
+ if (args.type === "drop") {
238
+ const options: Record<string, unknown> = {};
239
+ if (section !== undefined) options.section = section;
240
+ if (at !== undefined) options.bar = at;
241
+ if (args.cutBeats !== undefined) {
242
+ if (
243
+ typeof args.cutBeats !== "number" ||
244
+ !Number.isFinite(args.cutBeats) ||
245
+ args.cutBeats < DROP_CUT_MIN
246
+ )
247
+ throw new ToolArgumentError(
248
+ "cutBeats must be a number of beats, 0 up to two bars",
249
+ );
250
+ options.cut = args.cutBeats;
251
+ }
252
+ const impact = flag(args.impact, "impact");
253
+ if (impact !== undefined) options.impact = impact;
254
+ return { type: "drop", options };
255
+ }
256
+ if (args.type === "fill") {
257
+ const options: Record<string, unknown> = {};
258
+ if (section !== undefined) options.section = section;
259
+ if (at !== undefined) options.bar = at;
260
+ if (args.beats !== undefined) {
261
+ if (
262
+ typeof args.beats !== "number" ||
263
+ !Number.isFinite(args.beats) ||
264
+ args.beats < FILL_BEATS_MIN
265
+ )
266
+ throw new ToolArgumentError(
267
+ `beats must be a number of beats, ${FILL_BEATS_MIN} up to two bars`,
268
+ );
269
+ options.beats = args.beats;
270
+ }
271
+ if (args.style !== undefined) {
272
+ if (!FILL_STYLES.includes(args.style as FillStyle))
273
+ throw new ToolArgumentError(`style must be ${FILL_STYLES.join(", ")}`);
274
+ options.style = args.style;
275
+ }
276
+ const crash = flag(args.crash, "crash");
277
+ if (crash !== undefined) options.crash = crash;
278
+ return { type: "fill", options };
279
+ }
280
+ throw new ToolArgumentError("type must be build, drop or fill");
281
+ }
282
+
283
+ export const SECTION_TOOLS: readonly AgentTool[] = Object.freeze([
284
+ {
285
+ name: "list_sections",
286
+ description:
287
+ "Show the song's sections (bars count from 1), the form, the looped section and the arranged length.",
288
+ parameters: { type: "object", properties: {}, additionalProperties: false },
289
+ plan(_args, context) {
290
+ const score = context.score;
291
+ const sections = score.sections.map((section) => ({
292
+ name: section.name,
293
+ fromBar: section.startBar + 1,
294
+ bars: section.bars,
295
+ ...(section.mute ? { mute: section.mute } : {}),
296
+ ...(section.vary ? { vary: section.vary } : {}),
297
+ }));
298
+ return {
299
+ kind: "action",
300
+ summary: "list sections",
301
+ run: async () => ({
302
+ content: JSON.stringify({
303
+ ok: true,
304
+ sections,
305
+ form: formatForm(score.form) || null,
306
+ loopSection: score.loopSection ?? null,
307
+ arrangedBars: arrangedBars(score),
308
+ text: describeArrangement(score),
309
+ }),
310
+ summary: `${sections.length} sections`,
311
+ }),
312
+ };
313
+ },
314
+ },
315
+ {
316
+ name: "edit_section",
317
+ description:
318
+ "Arrange song sections (named bar ranges; bars count from 1). mark names bars fromBar..fromBar+bars-1 without moving music; add appends empty bars as a new section; duplicate copies a section's music after it; move moves its music (toBar, before or after a section); rename; delete removes its bars and music; unmark drops only the name; mute/unmute silences tracks in it; vary sets a track's transpose (semitones) and gain (0..2) in it (neither: clears); reset clears mutes and variations; loop makes playback cycle it; unloop plays the song.",
319
+ parameters: {
320
+ type: "object",
321
+ properties: {
322
+ action: { type: "string", enum: [...SECTION_ACTIONS] },
323
+ name,
324
+ fromBar: bar("mark: first bar, 1-based"),
325
+ bars: {
326
+ type: "integer",
327
+ minimum: 1,
328
+ maximum: SCORE_LIMITS.maxBars,
329
+ description: "mark/add: length in bars (add defaults to 8)",
330
+ },
331
+ to: {
332
+ type: "string",
333
+ maxLength: 32,
334
+ description: "rename: new name; duplicate: the copy's name",
335
+ },
336
+ toBar: bar("move: the bar the section starts at afterwards"),
337
+ before: { type: "string", maxLength: 32 },
338
+ after: { type: "string", maxLength: 32 },
339
+ tracks: {
340
+ type: "array",
341
+ items: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
342
+ maxItems: 64,
343
+ },
344
+ trackId: {
345
+ type: "string",
346
+ maxLength: SCORE_LIMITS.maxIdLength,
347
+ description: "vary: the track (default: the focused track)",
348
+ },
349
+ transpose: { type: "integer", minimum: -24, maximum: 24 },
350
+ gain: { type: "number", minimum: 0, maximum: 2 },
351
+ },
352
+ required: ["action"],
353
+ additionalProperties: false,
354
+ },
355
+ plan(args, context) {
356
+ return plan(context, sectionCommand(args));
357
+ },
358
+ },
359
+ {
360
+ name: "set_form",
361
+ description:
362
+ 'Set the song form: the order sections play in, with repeats, e.g. "intro verse chorus*2 verse chorus outro" (commas when a name has spaces). Playback and export follow it. An empty string clears it; bake writes the form out as plain bars (sections laid end to end) and clears it.',
363
+ parameters: {
364
+ type: "object",
365
+ properties: {
366
+ form: { type: "string", maxLength: 1000 },
367
+ bake: { type: "boolean" },
368
+ },
369
+ additionalProperties: false,
370
+ },
371
+ plan(args, context) {
372
+ if (args.bake === true) return plan(context, { type: "form-bake" });
373
+ if (typeof args.form !== "string")
374
+ throw new ToolArgumentError("set_form needs form or bake");
375
+ return plan(context, {
376
+ type: "form-set",
377
+ text: args.form,
378
+ });
379
+ },
380
+ },
381
+ {
382
+ name: "add_transition",
383
+ description: `Generate a transition with the built-in voices (bars count from 1). build: noise riser, accelerating snare roll, filter sweep and uplifter (all on by default), landing on the bar after its range. The range is \`bars\` bars (default 4) just before section \`into\` (the usual build into a drop or chorus); or a section's own bars (its last \`bars\` bars when bars is given); or \`bars\` bars starting at atBar; default the song's last 4 bars. The sweep is a high-pass up to 1.2 kHz on unfiltered or high-pass pitched tracks and a low-pass opening from cutoff/10 on low-pass tracks, on an octave curve, snapping back on the downbeat. drop: a pre-drop cut (cutBeats of silence, default 1, up to two bars) and an impact hit on the section's first bar or atBar (default: the section named drop, else chorus). fill: a drum fill (${FILL_STYLES.join(", ")}) in the last beats (default 1, ${FILL_BEATS_MIN} up to two bars) leading into the section or into atBar, plus a crash on its downbeat. Without section or atBar, fills go at every section boundary. Out-of-range values are clamped and the result says so.`,
384
+ parameters: {
385
+ type: "object",
386
+ properties: {
387
+ type: { type: "string", enum: ["build", "drop", "fill"] },
388
+ section: name,
389
+ into: name,
390
+ atBar: bar(
391
+ "build: first bar; drop: the bar it lands on; fill: the bar it leads into",
392
+ ),
393
+ bars: { type: "integer", minimum: 1, maximum: 64 },
394
+ riser: { type: "boolean" },
395
+ roll: { type: "boolean" },
396
+ sweep: { type: "boolean" },
397
+ uplifter: { type: "boolean" },
398
+ cutBeats: { type: "number", minimum: 0 },
399
+ impact: { type: "boolean" },
400
+ beats: { type: "number", minimum: FILL_BEATS_MIN },
401
+ style: { type: "string", enum: [...FILL_STYLES] },
402
+ crash: { type: "boolean" },
403
+ },
404
+ required: ["type"],
405
+ additionalProperties: false,
406
+ },
407
+ plan(args, context) {
408
+ return plan(context, transitionCommand(args));
409
+ },
410
+ },
411
+ ]);
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Agent tool for the tempo map, meter changes, fermatas and track time
3
+ * (`core/tempo.ts`): `set_time`. Each call is one revision built by the same
4
+ * code as the typed `tempo`, `rit`, `accel`, `fermata`, `meter` and
5
+ * `track rate|phase|cycle|phasing` commands (`src/commands/time.ts`).
6
+ */
7
+ import { diffScores } from "../../core/diff.ts";
8
+ import { SCORE_LIMITS } from "../../core/score.ts";
9
+ import { TIME_LIMITS, type TempoRamp } from "../../core/tempo.ts";
10
+ import {
11
+ applyTimeCommand,
12
+ FERMATA_DEFAULT_BEATS,
13
+ type TimeCommand,
14
+ type TimePosition,
15
+ } from "../commands/time.ts";
16
+ import type { AgentTool, ToolContext } from "./tools.ts";
17
+ import { ToolArgumentError } from "./tools.ts";
18
+
19
+ const ACTIONS = [
20
+ "tempo",
21
+ "rit",
22
+ "accel",
23
+ "a_tempo",
24
+ "tempo_primo",
25
+ "fermata",
26
+ "meter",
27
+ "track",
28
+ "phasing",
29
+ "remove_tempo",
30
+ "remove_fermata",
31
+ "remove_meter",
32
+ "clear",
33
+ ] as const;
34
+ type Action = (typeof ACTIONS)[number];
35
+
36
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
37
+
38
+ function num(
39
+ args: Record<string, unknown>,
40
+ key: string,
41
+ min: number,
42
+ max: number,
43
+ ): number | undefined {
44
+ const value = args[key];
45
+ if (value === undefined || value === null) return undefined;
46
+ if (typeof value !== "number" || !Number.isFinite(value))
47
+ throw new ToolArgumentError(`${key} must be a number`);
48
+ if (value < min || value > max)
49
+ throw new ToolArgumentError(`${key} must be between ${min} and ${max}`);
50
+ return value;
51
+ }
52
+
53
+ function positionOf(
54
+ args: Record<string, unknown>,
55
+ required: boolean,
56
+ ): TimePosition | undefined {
57
+ const beat = num(args, "beat", 0, 1_000_000);
58
+ const bar = num(args, "bar", 1, SCORE_LIMITS.maxBars);
59
+ if (beat !== undefined && bar !== undefined)
60
+ throw new ToolArgumentError("give beat or bar, not both");
61
+ if (bar !== undefined) {
62
+ if (!Number.isInteger(bar))
63
+ throw new ToolArgumentError("bar must be an integer");
64
+ return { bar };
65
+ }
66
+ if (beat !== undefined) return { beat };
67
+ if (required) throw new ToolArgumentError("needs beat or bar");
68
+ return undefined;
69
+ }
70
+
71
+ function rampOf(args: Record<string, unknown>): TempoRamp | undefined {
72
+ const ramp = args.ramp;
73
+ if (ramp === undefined || ramp === null || ramp === "step") return undefined;
74
+ if (ramp === "linear" || ramp === "exp") return ramp;
75
+ throw new ToolArgumentError("ramp must be step, linear or exp");
76
+ }
77
+
78
+ function trackIdOf(args: Record<string, unknown>, context: ToolContext) {
79
+ const trackId = args.trackId ?? context.focusedTrackId;
80
+ if (typeof trackId !== "string" || !ID_PATTERN.test(trackId))
81
+ throw new ToolArgumentError("trackId must be an existing track id");
82
+ if (!context.score.tracks.some((track) => track.id === trackId))
83
+ throw new ToolArgumentError(`no track ${trackId}`);
84
+ return trackId;
85
+ }
86
+
87
+ /** Builds the typed command for `set_time` arguments. */
88
+ export function timeToolCommand(args: Record<string, unknown>): TimeCommand {
89
+ const action = args.action as Action;
90
+ if (!ACTIONS.includes(action))
91
+ throw new ToolArgumentError(`action must be one of ${ACTIONS.join(", ")}`);
92
+ const bpm = () => num(args, "bpm", TIME_LIMITS.minBpm, TIME_LIMITS.maxBpm);
93
+ switch (action) {
94
+ case "tempo": {
95
+ const value = bpm();
96
+ if (value === undefined) throw new ToolArgumentError("tempo needs bpm");
97
+ const ramp = rampOf(args);
98
+ return {
99
+ type: "tempo-at",
100
+ bpm: value,
101
+ at: positionOf(args, true)!,
102
+ ...(ramp ? { ramp } : {}),
103
+ };
104
+ }
105
+ case "rit":
106
+ case "accel": {
107
+ const bars = num(args, "bars", 0.25, SCORE_LIMITS.maxBars);
108
+ const beats = num(args, "beats", 0.25, 100_000);
109
+ if (bars !== undefined && beats !== undefined)
110
+ throw new ToolArgumentError("give bars or beats, not both");
111
+ const value = bpm();
112
+ const at = positionOf(args, false);
113
+ return {
114
+ type: "gradual",
115
+ direction: action,
116
+ length: beats ?? bars ?? 2,
117
+ unit: beats !== undefined ? "beats" : "bars",
118
+ ...(value !== undefined ? { bpm: value } : {}),
119
+ ...(at ? { at } : {}),
120
+ curve: rampOf(args) ?? "linear",
121
+ };
122
+ }
123
+ case "a_tempo":
124
+ case "tempo_primo": {
125
+ const at = positionOf(args, false);
126
+ return {
127
+ type: "tempo-return",
128
+ primo: action === "tempo_primo",
129
+ ...(at ? { at } : {}),
130
+ };
131
+ }
132
+ case "fermata": {
133
+ const at = positionOf(args, false);
134
+ return {
135
+ type: "fermata",
136
+ ...(at ? { at } : {}),
137
+ beats:
138
+ num(args, "beats", 0.25, TIME_LIMITS.maxFermataBeats) ??
139
+ FERMATA_DEFAULT_BEATS,
140
+ };
141
+ }
142
+ case "meter": {
143
+ const meter = args.meter;
144
+ const match =
145
+ typeof meter === "string" ? /^(\d+)\/(\d+)$/.exec(meter) : null;
146
+ if (!match) throw new ToolArgumentError("meter must look like 7/8");
147
+ const bar = num(args, "bar", 1, SCORE_LIMITS.maxBars);
148
+ return {
149
+ type: "meter-at",
150
+ beatsPerBar: Number(match[1]),
151
+ beatUnit: Number(match[2]),
152
+ ...(bar !== undefined ? { bar: Math.round(bar) } : {}),
153
+ };
154
+ }
155
+ case "remove_tempo":
156
+ return { type: "tempo-remove", at: positionOf(args, true)! };
157
+ case "remove_fermata":
158
+ return { type: "fermata-remove", at: positionOf(args, true)! };
159
+ case "remove_meter": {
160
+ const bar = num(args, "bar", 1, SCORE_LIMITS.maxBars);
161
+ if (bar === undefined)
162
+ throw new ToolArgumentError("remove_meter needs bar");
163
+ return { type: "meter-remove", bar: Math.round(bar) };
164
+ }
165
+ case "phasing": {
166
+ const cycle = num(args, "cycle", 0.25, 100_000);
167
+ if (cycle === undefined)
168
+ throw new ToolArgumentError("phasing needs cycle (beats)");
169
+ const over = num(args, "over", 0.25, 1_000_000);
170
+ return {
171
+ type: "track-phasing",
172
+ cycle,
173
+ ...(over !== undefined ? { over } : {}),
174
+ cycles: num(args, "cycles", -64, 64) ?? 1,
175
+ ...stepArgs(args),
176
+ };
177
+ }
178
+ case "track":
179
+ case "clear":
180
+ throw new ToolArgumentError("handled separately");
181
+ }
182
+ }
183
+
184
+ const TRACK_FIELDS = ["rate", "phase", "cycle"] as const;
185
+
186
+ export const TIME_TOOLS: readonly AgentTool[] = Object.freeze([
187
+ {
188
+ name: "set_time",
189
+ description:
190
+ "Tempo map and meter: action tempo (bpm at beat|bar; ramp linear|exp glides into it), rit/accel (bars or beats long, optional target bpm and start; default 75%/133% over the last 2 bars), a_tempo/tempo_primo (step back to the tempo before the last rit/accel, or to the start tempo; at the bar after it unless beat|bar), fermata (hold beat|bar for beats extra beats), meter ('7/8' from bar, or the whole song without bar), remove_tempo/remove_fermata/remove_meter, clear (what: tempo|meter|fermatas|track|all). Per-track polytempo/polymeter: action track with rate (tempo ratio, 1.5 = three against two), phase (beats later) and cycle (beats per repeat), null resets one; action phasing (cycle beats, over beats, cycles n) sets a continuous drift that realigns at the loop end (over must divide the loop); with hold/drift (whole cycles) and shift (beats, default 0.25) it is stepped, as in Piano Phase: hold in step, then move shift ahead over drift cycles. Beats count from 0, bars from 1.",
191
+ parameters: {
192
+ type: "object",
193
+ properties: {
194
+ action: { type: "string", enum: [...ACTIONS] },
195
+ bpm: {
196
+ type: "number",
197
+ minimum: TIME_LIMITS.minBpm,
198
+ maximum: TIME_LIMITS.maxBpm,
199
+ },
200
+ beat: { type: "number", minimum: 0 },
201
+ bar: { type: "integer", minimum: 1, maximum: SCORE_LIMITS.maxBars },
202
+ ramp: { type: "string", enum: ["step", "linear", "exp"] },
203
+ bars: { type: "number", minimum: 0.25 },
204
+ beats: { type: "number", minimum: 0.25 },
205
+ meter: { type: "string", pattern: "^\\d+/\\d+$" },
206
+ what: {
207
+ type: "string",
208
+ enum: ["tempo", "meter", "fermatas", "track", "all"],
209
+ },
210
+ trackId: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
211
+ rate: { type: ["number", "null"] },
212
+ phase: { type: ["number", "null"] },
213
+ cycle: { type: ["number", "null"] },
214
+ over: { type: "number" },
215
+ cycles: { type: "number" },
216
+ hold: { type: "number" },
217
+ drift: { type: "number" },
218
+ shift: { type: "number" },
219
+ },
220
+ required: ["action"],
221
+ additionalProperties: false,
222
+ },
223
+ plan(args, context) {
224
+ let score = context.score;
225
+ const messages: string[] = [];
226
+ const run = (trackId: string, command: TimeCommand) => {
227
+ const result = applyTimeCommand(score, trackId, command);
228
+ if (!result.ok) throw new ToolArgumentError(result.message);
229
+ if (result.next) score = result.next;
230
+ messages.push(result.message);
231
+ };
232
+ const action = args.action;
233
+ let trackId: string | undefined;
234
+ if (action === "track") {
235
+ trackId = trackIdOf(args, context);
236
+ const given = TRACK_FIELDS.filter((field) => field in args);
237
+ if (given.length === 0)
238
+ throw new ToolArgumentError("track needs rate, phase or cycle");
239
+ for (const field of given) {
240
+ const value =
241
+ args[field] === null
242
+ ? null
243
+ : field === "rate"
244
+ ? num(args, field, TIME_LIMITS.minRate, TIME_LIMITS.maxRate)!
245
+ : num(args, field, -100_000, 100_000)!;
246
+ run(trackId, { type: "track-time", field, value });
247
+ }
248
+ } else if (action === "clear") {
249
+ const what = args.what ?? "all";
250
+ if (what === "tempo" || what === "all")
251
+ run("", { type: "tempo-clear" });
252
+ if (what === "meter" || what === "all")
253
+ run("", { type: "meter-clear" });
254
+ if (what === "fermatas" || what === "all")
255
+ run("", { type: "fermata-clear" });
256
+ if (what === "track") {
257
+ trackId = trackIdOf(args, context);
258
+ run(trackId, { type: "track-time-off" });
259
+ }
260
+ } else {
261
+ const command = timeToolCommand(args);
262
+ if (command.type === "track-phasing")
263
+ trackId = trackIdOf(args, context);
264
+ run(trackId ?? "", command);
265
+ }
266
+ return {
267
+ kind: "score",
268
+ operations: diffScores(context.score, score),
269
+ ...(trackId ? { trackId } : {}),
270
+ summary: messages.join(" · "),
271
+ };
272
+ },
273
+ },
274
+ ]);
275
+
276
+ /** Stepped-phasing arguments; present ones switch phasing to steps. */
277
+ function stepArgs(args: Record<string, unknown>): {
278
+ hold?: number;
279
+ drift?: number;
280
+ shift?: number;
281
+ } {
282
+ const out: { hold?: number; drift?: number; shift?: number } = {};
283
+ const hold = num(args, "hold", 0, 64);
284
+ const drift = num(args, "drift", 1, 64);
285
+ const shift = num(args, "shift", 0.0625, 100_000);
286
+ if (hold !== undefined) out.hold = Math.round(hold);
287
+ if (drift !== undefined) out.drift = Math.round(drift);
288
+ if (shift !== undefined) out.shift = shift;
289
+ return out;
290
+ }