@hraness/dawg 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
@@ -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,
@@ -16,6 +32,24 @@ import {
16
32
  synthParamName,
17
33
  } from "../../core/synth.ts";
18
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";
19
53
  import {
20
54
  setSampleControls,
21
55
  type SampleControlValue,
@@ -23,16 +57,26 @@ import {
23
57
  import {
24
58
  EFFECT_NAMES,
25
59
  FX_PRESETS,
60
+ RIG_STAGES,
26
61
  effectSpec,
27
62
  type EffectName,
28
63
  } from "../../core/fx.ts";
64
+ import {
65
+ RIG_PRESET_NAMES,
66
+ applyRigCommand,
67
+ parseRigCommand,
68
+ rigTrackFields,
69
+ rigWordPatch,
70
+ } from "../commands/rig.ts";
29
71
  import {
30
72
  applyFxCommand,
31
73
  effectPatch,
32
74
  effectValues,
33
75
  parseEffectName,
34
76
  parseParamName,
77
+ parseFxCommand,
35
78
  type FxCommand,
79
+ type FxResult,
36
80
  } from "../commands/fx.ts";
37
81
  import type { ChatTool } from "./gateway.ts";
38
82
  import { MEDIA_TOOLS } from "../media/tools.ts";
@@ -51,8 +95,9 @@ import { MASTER_TOOLS } from "./master-tools.ts";
51
95
  import { DRUM_TOOLS } from "./drum-tools.ts";
52
96
  import { TIME_TOOLS } from "./time-tools.ts";
53
97
  import { SECTION_TOOLS } from "./section-tools.ts";
98
+ import { GRANULAR_TOOLS } from "./granular-tools.ts";
54
99
  import type { MediaResult, MediaRunContext } from "../media/types.ts";
55
- import { pitchToMidi } from "./ops.ts";
100
+ import { instrumentPatch, pitchToMidi } from "./ops.ts";
56
101
  import { TUNING_LIMITS } from "../../core/tuning.ts";
57
102
  import {
58
103
  DRUM_VOICES,
@@ -86,6 +131,13 @@ import {
86
131
  type SearchSpend,
87
132
  } from "../web/search.ts";
88
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
+
89
141
  /** What a validated tool call asks the host to do. */
90
142
  export type ScorePlan = Readonly<{
91
143
  kind: "score";
@@ -421,24 +473,47 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
421
473
  },
422
474
  {
423
475
  name: "set_instrument",
424
- 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.",
425
478
  parameters: {
426
479
  type: "object",
427
480
  properties: {
428
481
  trackId: trackIdSchema,
429
- instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
482
+ instrument: { type: "string", enum: INSTRUMENT_ENUM },
430
483
  },
431
484
  required: ["instrument"],
432
485
  additionalProperties: false,
433
486
  },
434
487
  plan(args, context) {
435
488
  const trackId = targetTrack(args, context);
436
- 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);
437
507
  return {
438
508
  kind: "score",
439
- operations: [{ type: "updateTrack", trackId, patch: { instrument } }],
509
+ operations: [
510
+ { type: "updateTrack", trackId, patch: { ...patch, ...rig } },
511
+ ],
440
512
  trackId,
441
- 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}`,
442
517
  };
443
518
  },
444
519
  },
@@ -635,6 +710,92 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
635
710
  };
636
711
  },
637
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
+ },
638
799
  {
639
800
  name: "set_synth",
640
801
  description:
@@ -680,6 +841,198 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
680
841
  };
681
842
  },
682
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
+ },
683
1036
  {
684
1037
  name: "set_sample",
685
1038
  description:
@@ -727,6 +1080,55 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
727
1080
  };
728
1081
  },
729
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
+ },
730
1132
  {
731
1133
  name: "set_effects",
732
1134
  description: `Shorthand: low-pass filter, delay (beats) and reverb (mix 0.15..0.35 is a room); null removes one. Prefer set_fx.`,
@@ -1082,7 +1484,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1082
1484
  properties: {
1083
1485
  id: { type: "string", pattern: ID_PATTERN.source, maxLength: 64 },
1084
1486
  name: { type: "string", maxLength: SCORE_LIMITS.maxNameLength },
1085
- instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
1487
+ instrument: { type: "string", enum: INSTRUMENT_ENUM },
1086
1488
  },
1087
1489
  required: ["id", "instrument"],
1088
1490
  additionalProperties: false,
@@ -1095,14 +1497,46 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1095
1497
  const id = args.id;
1096
1498
  if (context.score.tracks.some((track) => track.id === id))
1097
1499
  throw new ToolArgumentError(`track ${id} already exists`);
1098
- const instrument = instrumentName(args.instrument);
1099
1500
  const name =
1100
1501
  typeof args.name === "string" && args.name.trim().length > 0
1101
1502
  ? args.name.trim().slice(0, SCORE_LIMITS.maxNameLength)
1102
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;
1103
1526
  return {
1104
1527
  kind: "score",
1105
- 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
+ ],
1106
1540
  trackId: id,
1107
1541
  summary: `+track ${id} (${instrument})`,
1108
1542
  };
@@ -1387,6 +1821,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1387
1821
  ...MEDIA_TOOLS,
1388
1822
  ...PACK_TOOLS,
1389
1823
  ...MASTER_TOOLS,
1824
+ ...GRANULAR_TOOLS,
1390
1825
  // Looks tools up at call time, so it can plan any of the above.
1391
1826
  previewSoundTool((name) => findAgentTool(name)),
1392
1827
  ] satisfies AgentTool[]);
@@ -1534,6 +1969,37 @@ function fxToolCommand(
1534
1969
  return { type: "fx-set", effect, values };
1535
1970
  }
1536
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. */
1537
2003
  function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1538
2004
  if (args.reset === true) return { type: "synth-reset" };
1539
2005
  if (args.zzfx !== undefined) {
@@ -1578,6 +2044,46 @@ function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1578
2044
  return { type: "synth-set", values };
1579
2045
  }
1580
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
+
1581
2087
  function targetTrack(args: Record<string, unknown>, context: ToolContext) {
1582
2088
  const trackId = args.trackId ?? context.focusedTrackId;
1583
2089
  if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
@@ -1597,15 +2103,33 @@ function knownNoteId(value: unknown, context: ToolContext, label: string) {
1597
2103
  return value;
1598
2104
  }
1599
2105
 
1600
- 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);
1601
2123
  if (
1602
- typeof value !== "string" ||
1603
- !(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))
1604
2128
  )
1605
2129
  throw new ToolArgumentError(
1606
- `instrument must be one of ${AVAILABLE_INSTRUMENTS.join(", ")}`,
2130
+ `instrument must be one of ${INSTRUMENT_ENUM.join(", ")}`,
1607
2131
  );
1608
- return value;
2132
+ return patch;
1609
2133
  }
1610
2134
 
1611
2135
  function pitch(value: unknown, label: string): number {