@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -1,3 +1,19 @@
1
+ import { INSTRUMENT_WORDS } from "../../core/instruments.ts";
2
+ import {
3
+ DEFAULT_STRING_PRESET,
4
+ STRING_INSTRUMENT,
5
+ } from "../../core/strings.ts";
6
+ import {
7
+ instrumentPatchForWord,
8
+ MODAL_ALIASES,
9
+ MODAL_MALLET_NAMES,
10
+ MODAL_PARAMS,
11
+ MODAL_PRESET_NAMES,
12
+ modalParamName,
13
+ modalPresetFor,
14
+ type TrackModal,
15
+ } from "../../core/resonators.ts";
16
+ import { applyModalCommand, type ModalCommand } from "../commands/modal.ts";
1
17
  import {
2
18
  AUTOMATION_PARAMETERS,
3
19
  automationPoints,
@@ -7,6 +23,7 @@ import {
7
23
  type ScoreOperation,
8
24
  type TrackScore,
9
25
  } from "../../core/score.ts";
26
+ import { loopTicksOf } from "../../core/tempo.ts";
10
27
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
11
28
  import {
12
29
  SYNTH_PRESETS,
@@ -15,6 +32,24 @@ import {
15
32
  synthParamName,
16
33
  } from "../../core/synth.ts";
17
34
  import { applySynthCommand, type SynthCommand } from "../commands/synth.ts";
35
+ import { applyStringCommand, type StringCommand } from "../commands/string.ts";
36
+ import {
37
+ STRING_PARAMS,
38
+ STRING_PRESET_NAMES,
39
+ stringParamName,
40
+ stringPresetName,
41
+ } from "../../core/strings.ts";
42
+ import {
43
+ applyKeysCommand,
44
+ keysPresetPatch,
45
+ type KeysCommand,
46
+ } from "../commands/keys.ts";
47
+ import {
48
+ KEYS_PRESETS,
49
+ KEYS_SIMPLE,
50
+ isKeysPreset,
51
+ keysParamName,
52
+ } from "../../core/keys.ts";
18
53
  import {
19
54
  setSampleControls,
20
55
  type SampleControlValue,
@@ -22,16 +57,26 @@ import {
22
57
  import {
23
58
  EFFECT_NAMES,
24
59
  FX_PRESETS,
60
+ RIG_STAGES,
25
61
  effectSpec,
26
62
  type EffectName,
27
63
  } from "../../core/fx.ts";
64
+ import {
65
+ RIG_PRESET_NAMES,
66
+ applyRigCommand,
67
+ parseRigCommand,
68
+ rigTrackFields,
69
+ rigWordPatch,
70
+ } from "../commands/rig.ts";
28
71
  import {
29
72
  applyFxCommand,
30
73
  effectPatch,
31
74
  effectValues,
32
75
  parseEffectName,
33
76
  parseParamName,
77
+ parseFxCommand,
34
78
  type FxCommand,
79
+ type FxResult,
35
80
  } from "../commands/fx.ts";
36
81
  import type { ChatTool } from "./gateway.ts";
37
82
  import { MEDIA_TOOLS } from "../media/tools.ts";
@@ -44,9 +89,16 @@ import {
44
89
  import { PackError, type PackStore } from "../audio/packs.ts";
45
90
  import { RHYTHM_TOOLS } from "./rhythm-tools.ts";
46
91
  import { CHORD_TOOLS } from "./chord-tools.ts";
92
+ import { EXPRESSION_TOOLS } from "./expression-tools.ts";
93
+ import { TUNING_TOOLS } from "./tuning-tools.ts";
94
+ import { MASTER_TOOLS } from "./master-tools.ts";
47
95
  import { DRUM_TOOLS } from "./drum-tools.ts";
96
+ import { TIME_TOOLS } from "./time-tools.ts";
97
+ import { SECTION_TOOLS } from "./section-tools.ts";
98
+ import { GRANULAR_TOOLS } from "./granular-tools.ts";
48
99
  import type { MediaResult, MediaRunContext } from "../media/types.ts";
49
- import { pitchToMidi } from "./ops.ts";
100
+ import { instrumentPatch, pitchToMidi } from "./ops.ts";
101
+ import { TUNING_LIMITS } from "../../core/tuning.ts";
50
102
  import {
51
103
  DRUM_VOICES,
52
104
  drumVoicePitch,
@@ -79,6 +131,13 @@ import {
79
131
  type SearchSpend,
80
132
  } from "../web/search.ts";
81
133
 
134
+ /** Instrument words the tools accept: legacy voices, `string`, 0.6 words. */
135
+ const INSTRUMENT_ENUM: string[] = [
136
+ ...AVAILABLE_INSTRUMENTS,
137
+ STRING_INSTRUMENT,
138
+ ...INSTRUMENT_WORDS.map((row) => row.word),
139
+ ].filter((word, index, all) => all.indexOf(word) === index);
140
+
82
141
  /** What a validated tool call asks the host to do. */
83
142
  export type ScorePlan = Readonly<{
84
143
  kind: "score";
@@ -244,6 +303,12 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
244
303
  description: "Length in beats",
245
304
  },
246
305
  velocity: { type: "number", minimum: 0, maximum: 1 },
306
+ cents: {
307
+ type: "number",
308
+ minimum: -TUNING_LIMITS.maxNoteCents,
309
+ maximum: TUNING_LIMITS.maxNoteCents,
310
+ description: "Detune from the tuned pitch (microtones)",
311
+ },
247
312
  },
248
313
  required: ["pitch", "start", "duration"],
249
314
  additionalProperties: false,
@@ -262,6 +327,10 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
262
327
  const start = number(note, "start", { min: 0 });
263
328
  const duration = number(note, "duration", { min: 0, exclusive: true });
264
329
  const velocity = optionalNumber(note, "velocity", { min: 0, max: 1 });
330
+ const cents = optionalNumber(note, "cents", {
331
+ min: -TUNING_LIMITS.maxNoteCents,
332
+ max: TUNING_LIMITS.maxNoteCents,
333
+ });
265
334
  return {
266
335
  type: "addNote",
267
336
  note: {
@@ -271,6 +340,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
271
340
  durationTicks: Math.max(1, Math.round(duration * tpb)),
272
341
  pitch: pitch(note.pitch, `notes[${index}].pitch`),
273
342
  velocity: velocity ?? 0.8,
343
+ ...(cents ? { cents } : {}),
274
344
  },
275
345
  };
276
346
  });
@@ -322,7 +392,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
322
392
  {
323
393
  name: "update_notes",
324
394
  description:
325
- "Move, resize, transpose, or re-velocity existing notes by id. Times are in beats.",
395
+ "Move, resize, transpose, re-velocity or detune existing notes by id. Times are in beats.",
326
396
  parameters: {
327
397
  type: "object",
328
398
  properties: {
@@ -343,6 +413,12 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
343
413
  ],
344
414
  },
345
415
  velocity: { type: "number", minimum: 0, maximum: 1 },
416
+ cents: {
417
+ type: "number",
418
+ minimum: -TUNING_LIMITS.maxNoteCents,
419
+ maximum: TUNING_LIMITS.maxNoteCents,
420
+ description: "Detune from the tuned pitch; 0 clears it",
421
+ },
346
422
  },
347
423
  required: ["noteId"],
348
424
  additionalProperties: false,
@@ -378,6 +454,11 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
378
454
  max: 1,
379
455
  });
380
456
  if (velocity !== undefined) patch.velocity = velocity;
457
+ const cents = optionalNumber(update, "cents", {
458
+ min: -TUNING_LIMITS.maxNoteCents,
459
+ max: TUNING_LIMITS.maxNoteCents,
460
+ });
461
+ if (cents !== undefined) patch.cents = cents;
381
462
  if (Object.keys(patch).length === 0)
382
463
  throw new ToolArgumentError(`updates[${index}] changes nothing`);
383
464
  return { type: "updateNote", noteId, patch };
@@ -392,24 +473,47 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
392
473
  },
393
474
  {
394
475
  name: "set_instrument",
395
- description: "Change a track's instrument voice.",
476
+ description:
477
+ "Change a track's instrument voice. Mallets and bells (modal): modal (marimba) vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani; set_modal shapes them.",
396
478
  parameters: {
397
479
  type: "object",
398
480
  properties: {
399
481
  trackId: trackIdSchema,
400
- instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
482
+ instrument: { type: "string", enum: INSTRUMENT_ENUM },
401
483
  },
402
484
  required: ["instrument"],
403
485
  additionalProperties: false,
404
486
  },
405
487
  plan(args, context) {
406
488
  const trackId = targetTrack(args, context);
407
- const instrument = instrumentName(args.instrument);
489
+ // piano/grand/upright/felt…: a new write is the modelled piano.
490
+ const track = context.score.tracks.find((t) => t.id === trackId);
491
+ if (track && typeof args.instrument === "string") {
492
+ const word = args.instrument.toLowerCase();
493
+ const preset = word === "piano" ? "grand" : word;
494
+ if (isKeysPreset(preset)) {
495
+ const patch = keysPresetPatch(track, preset);
496
+ return {
497
+ kind: "score",
498
+ operations: [{ type: "updateTrack", trackId, patch }],
499
+ trackId,
500
+ summary: `${trackId} → ${patch.instrument} (${preset})`,
501
+ };
502
+ }
503
+ }
504
+ const patch = instrumentName(args.instrument);
505
+ // A guitar alias (`jangle`, `gtr-metal`…) also loads its rig.
506
+ const rig = rigWordPatch(String(args.instrument), track?.fx);
408
507
  return {
409
508
  kind: "score",
410
- operations: [{ type: "updateTrack", trackId, patch: { instrument } }],
509
+ operations: [
510
+ { type: "updateTrack", trackId, patch: { ...patch, ...rig } },
511
+ ],
411
512
  trackId,
412
- summary: `${trackId} → ${instrument}`,
513
+ summary:
514
+ patch.instrument === "marimba" && !patch.modal
515
+ ? `${trackId} → marimba (legacy tone) · set_modal preset marimba for the mallet engine`
516
+ : `${trackId} → ${patch.string?.preset ?? patch.modal?.preset ?? patch.instrument}`,
413
517
  };
414
518
  },
415
519
  },
@@ -606,6 +710,92 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
606
710
  };
607
711
  },
608
712
  },
713
+ {
714
+ name: "set_rig",
715
+ description:
716
+ "Guitar rig on a track (stomp → amp head with noise gate → speaker cab, before tremolo). rig loads a whole rig (clean crunch punk ragged lead metal fuzz octave funk wah bachata spring bassdrive reese jangle alt) or reset removes it; stomp/head/cab set stage params (stomp type fuzz|face|od|rat|octave gain tone level; head type clean|chime|crunch|lead|high|solid|bass gain bass mid treble presence master sag gate(dB); cab type 1x12|2x12|4x12|1x10|open|8x10|1x15|di mic), or null removes a stage. Heads are level-matched. `amp` is Strudel gain, not this.",
717
+ parameters: {
718
+ type: "object",
719
+ properties: {
720
+ trackId: trackIdSchema,
721
+ rig: { type: "string", enum: [...RIG_PRESET_NAMES, "reset"] },
722
+ ...Object.fromEntries(
723
+ RIG_STAGES.map((stage) => [
724
+ stage,
725
+ {
726
+ type: ["object", "null"],
727
+ additionalProperties: { type: ["number", "string"] },
728
+ },
729
+ ]),
730
+ ),
731
+ },
732
+ additionalProperties: false,
733
+ },
734
+ plan(args, context) {
735
+ const trackId = targetTrack(args, context);
736
+ let score = context.score;
737
+ const messages: string[] = [];
738
+ const run = (result: FxResult) => {
739
+ if (!result.ok) throw new ToolArgumentError(result.message);
740
+ if (result.next) score = result.next;
741
+ messages.push(result.message);
742
+ };
743
+ if (args.rig !== undefined) {
744
+ if (typeof args.rig !== "string")
745
+ throw new ToolArgumentError("rig must be a rig name or reset");
746
+ const command = parseRigCommand(`rig ${args.rig}`);
747
+ if (!command || command.type === "rig-show")
748
+ throw new ToolArgumentError(
749
+ `rig must be one of ${RIG_PRESET_NAMES.join(", ")} or reset`,
750
+ );
751
+ run(applyRigCommand(score, trackId, command));
752
+ }
753
+ for (const stage of RIG_STAGES) {
754
+ const params = args[stage];
755
+ if (params === undefined) continue;
756
+ if (params === null) {
757
+ run(
758
+ applyFxCommand(score, trackId, { type: "fx-off", effect: stage }),
759
+ );
760
+ continue;
761
+ }
762
+ if (typeof params !== "object" || Array.isArray(params))
763
+ throw new ToolArgumentError(`${stage} must be an object or null`);
764
+ const words = Object.entries(params as Record<string, unknown>)
765
+ .map(([key, value]) => `${key} ${String(value)}`)
766
+ .join(" ");
767
+ const command = words.length
768
+ ? parseFxCommand(`fx ${stage} ${words}`)
769
+ : ({ type: "fx-on", effect: stage } as const);
770
+ if (!command)
771
+ throw new ToolArgumentError(
772
+ `${stage}: unknown parameter or value in ${words}`,
773
+ );
774
+ run(applyFxCommand(score, trackId, command));
775
+ }
776
+ if (messages.length === 0)
777
+ throw new ToolArgumentError("give rig, stomp, head or cab");
778
+ const next = score.tracks.find((t) => t.id === trackId)!;
779
+ const before = context.score.tracks.find((t) => t.id === trackId)!;
780
+ return {
781
+ kind: "score",
782
+ operations: [
783
+ {
784
+ type: "updateTrack",
785
+ trackId,
786
+ patch: {
787
+ fx: next.fx ?? null,
788
+ ...(next.reverb !== before.reverb
789
+ ? { reverb: next.reverb ?? null }
790
+ : {}),
791
+ },
792
+ },
793
+ ],
794
+ trackId,
795
+ summary: `${trackId} ${messages.join(" · ")}`,
796
+ };
797
+ },
798
+ },
609
799
  {
610
800
  name: "set_synth",
611
801
  description:
@@ -651,6 +841,198 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
651
841
  };
652
842
  },
653
843
  },
844
+ {
845
+ name: "set_string",
846
+ description:
847
+ "Make a track a plucked string (physical model): preset picks the instrument; params override it (ring s, bright, damp, pos, mute, buzz = jawari, body, sym = sympathetic strings, stiff, exciter pick|finger|hammer|noise); null unsets one. reset keeps the preset and drops overrides; off returns the track to a plain pluck voice.",
848
+ parameters: {
849
+ type: "object",
850
+ properties: {
851
+ trackId: trackIdSchema,
852
+ preset: { type: "string", enum: [...STRING_PRESET_NAMES] },
853
+ reset: { type: "boolean" },
854
+ off: { type: "boolean" },
855
+ params: {
856
+ type: "object",
857
+ additionalProperties: { type: ["number", "string", "null"] },
858
+ },
859
+ },
860
+ additionalProperties: false,
861
+ },
862
+ plan(args, context) {
863
+ const trackId = targetTrack(args, context);
864
+ let score = context.score;
865
+ const messages: string[] = [];
866
+ for (const command of stringToolCommands(args)) {
867
+ const result = applyStringCommand(score, trackId, command);
868
+ if (!result.ok) throw new ToolArgumentError(result.message);
869
+ if (result.next) score = result.next;
870
+ messages.push(result.message);
871
+ }
872
+ const next = score.tracks.find((t) => t.id === trackId)!;
873
+ return {
874
+ kind: "score",
875
+ operations: [
876
+ {
877
+ type: "updateTrack",
878
+ trackId,
879
+ patch: { instrument: next.instrument, string: next.string ?? null },
880
+ },
881
+ ],
882
+ trackId,
883
+ summary: `${trackId} ${messages.at(-1)}`,
884
+ };
885
+ },
886
+ },
887
+ {
888
+ name: "set_keys",
889
+ description: `Shape a modelled piano: preset (instrument, keys and its effects), params ${KEYS_SIMPLE.join(" ")}… (null unsets; DAWG.md lists all), or reset. Stored "piano" stays legacy; set_instrument piano writes grand.`,
890
+ parameters: {
891
+ type: "object",
892
+ properties: {
893
+ trackId: trackIdSchema,
894
+ preset: { type: "string", enum: Object.keys(KEYS_PRESETS) },
895
+ reset: { type: "boolean" },
896
+ params: {
897
+ type: "object",
898
+ additionalProperties: { type: ["number", "string", "null"] },
899
+ },
900
+ },
901
+ additionalProperties: false,
902
+ },
903
+ plan(args, context) {
904
+ const trackId = targetTrack(args, context);
905
+ const command = keysToolCommand(args);
906
+ const result = applyKeysCommand(context.score, trackId, command);
907
+ if (!result.ok || !result.next)
908
+ throw new ToolArgumentError(result.message);
909
+ const before = context.score.tracks.find((t) => t.id === trackId)!;
910
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
911
+ return {
912
+ kind: "score",
913
+ operations: [
914
+ {
915
+ type: "updateTrack",
916
+ trackId,
917
+ patch: {
918
+ instrument: next.instrument,
919
+ keys: next.keys ?? null,
920
+ ...(next.filter !== before.filter
921
+ ? { filter: next.filter ?? null }
922
+ : {}),
923
+ ...(next.fx !== before.fx ? { fx: next.fx ?? null } : {}),
924
+ ...(next.reverb !== before.reverb
925
+ ? { reverb: next.reverb ?? null }
926
+ : {}),
927
+ },
928
+ },
929
+ ],
930
+ trackId,
931
+ summary: `${trackId} ${result.message}`,
932
+ };
933
+ },
934
+ },
935
+ {
936
+ name: "set_modal",
937
+ description:
938
+ "Mallets and bells on the modal engine: preset (marimba vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani) switches the voice and keeps overrides; mallet yarn|cord|rubber|plastic|brass; params sets MODAL_PARAMS (hardness position ring tilt release damp motor motordepth ombak buzz click strikebend strikedecay gain), null returns one to the preset; reset clears overrides. Turns the track into instrument modal.",
939
+ parameters: {
940
+ type: "object",
941
+ properties: {
942
+ trackId: trackIdSchema,
943
+ preset: {
944
+ type: "string",
945
+ enum: [...MODAL_PRESET_NAMES, ...Object.keys(MODAL_ALIASES)],
946
+ },
947
+ mallet: { type: "string", enum: [...MODAL_MALLET_NAMES] },
948
+ reset: { type: "boolean" },
949
+ params: {
950
+ type: "object",
951
+ additionalProperties: { type: ["number", "string", "null"] },
952
+ },
953
+ },
954
+ additionalProperties: false,
955
+ },
956
+ plan(args, context) {
957
+ const trackId = targetTrack(args, context);
958
+ const commands: ModalCommand[] = [];
959
+ if (args.reset === true) commands.push({ type: "modal-reset" });
960
+ if (args.preset !== undefined) {
961
+ const preset =
962
+ typeof args.preset === "string"
963
+ ? modalPresetFor(args.preset)
964
+ : undefined;
965
+ if (!preset)
966
+ throw new ToolArgumentError(
967
+ `preset must be one of ${MODAL_PRESET_NAMES.join(", ")}`,
968
+ );
969
+ commands.push({ type: "modal-preset", preset });
970
+ }
971
+ const values: Record<string, number | string | null> = {};
972
+ if (args.mallet !== undefined) {
973
+ if (typeof args.mallet !== "string")
974
+ throw new ToolArgumentError("mallet must be a string");
975
+ values.mallet = args.mallet;
976
+ }
977
+ if (args.params !== undefined) {
978
+ if (
979
+ typeof args.params !== "object" ||
980
+ args.params === null ||
981
+ Array.isArray(args.params)
982
+ )
983
+ throw new ToolArgumentError("params must be an object");
984
+ for (const [key, value] of Object.entries(args.params)) {
985
+ const name = modalParamName(key);
986
+ if (!name)
987
+ throw new ToolArgumentError(
988
+ `modal has no parameter ${key} (${Object.keys(MODAL_PARAMS).join(" ")})`,
989
+ );
990
+ if (
991
+ value !== null &&
992
+ typeof value !== "number" &&
993
+ typeof value !== "string"
994
+ )
995
+ throw new ToolArgumentError(`${key} must be a number or string`);
996
+ values[name] = value;
997
+ }
998
+ }
999
+ if (Object.keys(values).length > 0)
1000
+ commands.push({ type: "modal-set", values });
1001
+ if (commands.length === 0) {
1002
+ // An empty call turns the track modal and keeps a modal track's preset.
1003
+ const current = context.score.tracks.find((t) => t.id === trackId);
1004
+ commands.push({
1005
+ type: "modal-preset",
1006
+ preset:
1007
+ current?.instrument === "modal" && current.modal?.preset
1008
+ ? current.modal.preset
1009
+ : "marimba",
1010
+ });
1011
+ }
1012
+ let score = context.score;
1013
+ const messages: string[] = [];
1014
+ for (const command of commands) {
1015
+ const result = applyModalCommand(score, trackId, command);
1016
+ if (!result.ok || !result.next)
1017
+ throw new ToolArgumentError(result.message);
1018
+ score = result.next;
1019
+ messages.push(result.message);
1020
+ }
1021
+ const next = score.tracks.find((t) => t.id === trackId)!;
1022
+ return {
1023
+ kind: "score",
1024
+ operations: [
1025
+ {
1026
+ type: "updateTrack",
1027
+ trackId,
1028
+ patch: { instrument: next.instrument, modal: next.modal ?? null },
1029
+ },
1030
+ ],
1031
+ trackId,
1032
+ summary: `${trackId} ${messages.at(-1)}`,
1033
+ };
1034
+ },
1035
+ },
654
1036
  {
655
1037
  name: "set_sample",
656
1038
  description:
@@ -698,6 +1080,55 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
698
1080
  };
699
1081
  },
700
1082
  },
1083
+ {
1084
+ name: "fit_sample",
1085
+ description:
1086
+ "Fit a sampler voice to the song's tempo map (ramps included): bpm is the sample's own tempo (20..400), len its length in beats; fitmode repitch (tape: pitch moves with speed, default) | beats (drums, speech: onset slices placed on time, unstretched) | tones (pads, vocals: phase-vocoder stretch, pitch kept). fit on > bpm > len. null unsets one. Song tempo is set_tempo, not this.",
1087
+ parameters: {
1088
+ type: "object",
1089
+ properties: {
1090
+ trackId: trackIdSchema,
1091
+ voice: { type: "string" },
1092
+ bpm: { type: ["number", "null"], minimum: 20, maximum: 400 },
1093
+ fitmode: {
1094
+ anyOf: [
1095
+ { type: "string", enum: ["repitch", "beats", "tones"] },
1096
+ { type: "null" },
1097
+ ],
1098
+ },
1099
+ len: { type: ["number", "null"], exclusiveMinimum: 0, maximum: 1024 },
1100
+ },
1101
+ required: ["voice"],
1102
+ additionalProperties: false,
1103
+ },
1104
+ plan(args, context) {
1105
+ const trackId = targetTrack(args, context);
1106
+ if (typeof args.voice !== "string")
1107
+ throw new ToolArgumentError("fit_sample needs voice");
1108
+ const values: Record<string, SampleControlValue> = {};
1109
+ for (const key of ["bpm", "len", "fitmode"] as const)
1110
+ if (args[key] !== undefined)
1111
+ values[key] = args[key] as SampleControlValue;
1112
+ if (Object.keys(values).length === 0)
1113
+ throw new ToolArgumentError("fit_sample needs bpm, len or fitmode");
1114
+ const result = setSampleControls(
1115
+ context.score,
1116
+ trackId,
1117
+ args.voice,
1118
+ values,
1119
+ );
1120
+ if (!result.ok) throw new ToolArgumentError(result.message);
1121
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
1122
+ return {
1123
+ kind: "score",
1124
+ operations: [
1125
+ { type: "updateTrack", trackId, patch: { sampler: next.sampler } },
1126
+ ],
1127
+ trackId,
1128
+ summary: `${trackId} ${result.message.replace(/^sample/, "fit")}`,
1129
+ };
1130
+ },
1131
+ },
701
1132
  {
702
1133
  name: "set_effects",
703
1134
  description: `Shorthand: low-pass filter, delay (beats) and reverb (mix 0.15..0.35 is a room); null removes one. Prefer set_fx.`,
@@ -915,7 +1346,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
915
1346
  `track ${trackId} is ${track.instrument}, not a drum kit; set_instrument kit or create a kit track`,
916
1347
  );
917
1348
  const tpb = context.score.ticksPerBeat;
918
- const loopBeats = context.score.bars * context.score.beatsPerBar;
1349
+ const loopBeats = loopTicksOf(context.score) / tpb;
919
1350
  const voiceOf = (value: unknown, label: string) => {
920
1351
  const voice =
921
1352
  typeof value === "string" ? parseDrumVoice(value) : undefined;
@@ -1053,7 +1484,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1053
1484
  properties: {
1054
1485
  id: { type: "string", pattern: ID_PATTERN.source, maxLength: 64 },
1055
1486
  name: { type: "string", maxLength: SCORE_LIMITS.maxNameLength },
1056
- instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
1487
+ instrument: { type: "string", enum: INSTRUMENT_ENUM },
1057
1488
  },
1058
1489
  required: ["id", "instrument"],
1059
1490
  additionalProperties: false,
@@ -1066,14 +1497,46 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1066
1497
  const id = args.id;
1067
1498
  if (context.score.tracks.some((track) => track.id === id))
1068
1499
  throw new ToolArgumentError(`track ${id} already exists`);
1069
- const instrument = instrumentName(args.instrument);
1070
1500
  const name =
1071
1501
  typeof args.name === "string" && args.name.trim().length > 0
1072
1502
  ? args.name.trim().slice(0, SCORE_LIMITS.maxNameLength)
1073
1503
  : id;
1504
+ const word =
1505
+ typeof args.instrument === "string"
1506
+ ? args.instrument.toLowerCase()
1507
+ : "";
1508
+ const preset = word === "piano" ? "grand" : word;
1509
+ if (isKeysPreset(preset)) {
1510
+ const patch = keysPresetPatch({}, preset);
1511
+ return {
1512
+ kind: "score",
1513
+ operations: [
1514
+ {
1515
+ type: "addTrack",
1516
+ track: { id, name, ...patch, instrument: patch.instrument! },
1517
+ },
1518
+ ],
1519
+ trackId: id,
1520
+ summary: `+track ${id} (${patch.instrument} ${preset})`,
1521
+ };
1522
+ }
1523
+ const patch = instrumentName(args.instrument);
1524
+ const instrument =
1525
+ patch.string?.preset ?? patch.modal?.preset ?? patch.instrument;
1074
1526
  return {
1075
1527
  kind: "score",
1076
- operations: [{ type: "addTrack", track: { id, name, instrument } }],
1528
+ operations: [
1529
+ {
1530
+ type: "addTrack",
1531
+ track: {
1532
+ id,
1533
+ name,
1534
+ ...patch,
1535
+ // A guitar alias also starts with its rig.
1536
+ ...rigTrackFields(String(args.instrument)),
1537
+ },
1538
+ },
1539
+ ],
1077
1540
  trackId: id,
1078
1541
  summary: `+track ${id} (${instrument})`,
1079
1542
  };
@@ -1350,9 +1813,15 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1350
1813
  },
1351
1814
  ...RHYTHM_TOOLS,
1352
1815
  ...CHORD_TOOLS,
1816
+ ...EXPRESSION_TOOLS,
1817
+ ...TUNING_TOOLS,
1353
1818
  ...DRUM_TOOLS,
1819
+ ...TIME_TOOLS,
1820
+ ...SECTION_TOOLS,
1354
1821
  ...MEDIA_TOOLS,
1355
1822
  ...PACK_TOOLS,
1823
+ ...MASTER_TOOLS,
1824
+ ...GRANULAR_TOOLS,
1356
1825
  // Looks tools up at call time, so it can plan any of the above.
1357
1826
  previewSoundTool((name) => findAgentTool(name)),
1358
1827
  ] satisfies AgentTool[]);
@@ -1500,6 +1969,37 @@ function fxToolCommand(
1500
1969
  return { type: "fx-set", effect, values };
1501
1970
  }
1502
1971
 
1972
+ function keysToolCommand(args: Record<string, unknown>): KeysCommand {
1973
+ if (args.reset === true) return { type: "keys-reset" };
1974
+ if (args.preset !== undefined) {
1975
+ if (typeof args.preset !== "string" || !isKeysPreset(args.preset))
1976
+ throw new ToolArgumentError(
1977
+ `keys presets: ${Object.keys(KEYS_PRESETS).join(", ")}`,
1978
+ );
1979
+ return { type: "keys-preset", preset: args.preset };
1980
+ }
1981
+ if (args.params === undefined)
1982
+ throw new ToolArgumentError("set_keys needs preset, reset, or params");
1983
+ const params = record(args.params, "params");
1984
+ const values: Record<string, number | string | null> = {};
1985
+ for (const [name, value] of Object.entries(params)) {
1986
+ const param = keysParamName(name);
1987
+ if (!param)
1988
+ throw new ToolArgumentError(
1989
+ `keys has no parameter ${name} (DAWG.md lists them under Keys)`,
1990
+ );
1991
+ if (
1992
+ value !== null &&
1993
+ typeof value !== "number" &&
1994
+ typeof value !== "string"
1995
+ )
1996
+ throw new ToolArgumentError(`keys ${param} takes a number or a word`);
1997
+ values[param] = value;
1998
+ }
1999
+ return { type: "keys-set", values };
2000
+ }
2001
+
2002
+ /** Instrument words the agent may pick: the voice bank plus the pianos. */
1503
2003
  function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1504
2004
  if (args.reset === true) return { type: "synth-reset" };
1505
2005
  if (args.zzfx !== undefined) {
@@ -1544,6 +2044,46 @@ function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1544
2044
  return { type: "synth-set", values };
1545
2045
  }
1546
2046
 
2047
+ function stringToolCommands(args: Record<string, unknown>): StringCommand[] {
2048
+ if (args.off === true) return [{ type: "string-off" }];
2049
+ const commands: StringCommand[] = [];
2050
+ if (args.preset !== undefined) {
2051
+ const preset =
2052
+ typeof args.preset === "string"
2053
+ ? stringPresetName(args.preset)
2054
+ : undefined;
2055
+ if (!preset)
2056
+ throw new ToolArgumentError(
2057
+ `string presets: ${STRING_PRESET_NAMES.join(", ")}`,
2058
+ );
2059
+ commands.push({ type: "string-preset", preset });
2060
+ }
2061
+ if (args.reset === true) commands.push({ type: "string-reset" });
2062
+ if (args.params !== undefined) {
2063
+ const values: Record<string, number | string | null> = {};
2064
+ for (const [name, value] of Object.entries(record(args.params, "params"))) {
2065
+ const param = stringParamName(name);
2066
+ if (!param)
2067
+ throw new ToolArgumentError(
2068
+ `string has no parameter ${name}; params: ${Object.keys(STRING_PARAMS).join(", ")}`,
2069
+ );
2070
+ if (
2071
+ value !== null &&
2072
+ typeof value !== "number" &&
2073
+ typeof value !== "string"
2074
+ )
2075
+ throw new ToolArgumentError(`string ${name} must be a value`);
2076
+ values[param] = value;
2077
+ }
2078
+ commands.push({ type: "string-set", values });
2079
+ }
2080
+ if (commands.length === 0)
2081
+ throw new ToolArgumentError(
2082
+ "set_string needs preset, params, reset or off",
2083
+ );
2084
+ return commands;
2085
+ }
2086
+
1547
2087
  function targetTrack(args: Record<string, unknown>, context: ToolContext) {
1548
2088
  const trackId = args.trackId ?? context.focusedTrackId;
1549
2089
  if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
@@ -1563,15 +2103,33 @@ function knownNoteId(value: unknown, context: ToolContext, label: string) {
1563
2103
  return value;
1564
2104
  }
1565
2105
 
1566
- function instrumentName(value: unknown): string {
2106
+ /**
2107
+ * The track patch an instrument word means: legacy words store themselves,
2108
+ * `string` and the string resolver words (nylon, koto, harp…) also write
2109
+ * the `Track.string` preset the engine needs.
2110
+ */
2111
+ function instrumentName(value: unknown): {
2112
+ instrument: string;
2113
+ string?: { preset: string };
2114
+ modal?: TrackModal;
2115
+ } {
2116
+ const word = typeof value === "string" ? value.trim() : undefined;
2117
+ if (word === STRING_INSTRUMENT)
2118
+ return {
2119
+ instrument: STRING_INSTRUMENT,
2120
+ string: { preset: DEFAULT_STRING_PRESET },
2121
+ };
2122
+ const patch = word === undefined ? undefined : instrumentPatch(word);
1567
2123
  if (
1568
- typeof value !== "string" ||
1569
- !(AVAILABLE_INSTRUMENTS as readonly string[]).includes(value)
2124
+ patch === undefined ||
2125
+ (!patch.string &&
2126
+ !patch.modal &&
2127
+ !(AVAILABLE_INSTRUMENTS as readonly string[]).includes(patch.instrument))
1570
2128
  )
1571
2129
  throw new ToolArgumentError(
1572
- `instrument must be one of ${AVAILABLE_INSTRUMENTS.join(", ")}`,
2130
+ `instrument must be one of ${INSTRUMENT_ENUM.join(", ")}`,
1573
2131
  );
1574
- return value;
2132
+ return patch;
1575
2133
  }
1576
2134
 
1577
2135
  function pitch(value: unknown, label: string): number {