@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -0,0 +1,925 @@
1
+ /**
2
+ * 0.7 Voice agent tools, one array per lane. The contract spreads
3
+ * `VOICE_TOOLS` once into `AGENT_TOOLS` and `VOICE_PREVIEWABLE_TOOLS` once
4
+ * into `preview_sound`'s list; each lane fills only its own array and flags
5
+ * tools that change how a track sounds with `previewable: true`.
6
+ */
7
+ import { FX_PRESETS } from "../../core/fx.ts";
8
+ import {
9
+ applyFxCommand,
10
+ effectPatch,
11
+ effectValues,
12
+ FORMANT_VOWEL_HINT,
13
+ type FxCommand,
14
+ } from "../commands/fx.ts";
15
+ import {
16
+ isSingPreset,
17
+ normalizeVowel,
18
+ SING_INSTRUMENT,
19
+ SING_PARAMS,
20
+ SING_PRESET_NAMES,
21
+ singParamName,
22
+ } from "../../core/sing.ts";
23
+ import { SCORE_LIMITS } from "../../core/score.ts";
24
+ import {
25
+ applySingCommand,
26
+ assignVowels,
27
+ type SingCommand,
28
+ type SingValue,
29
+ } from "../commands/sing.ts";
30
+ import { ToolArgumentError } from "./tool-error.ts";
31
+ // Types only: tools.ts spreads VOICE_TOOLS, so a value import would cycle.
32
+ import type { AgentTool, ToolContext } from "./tools.ts";
33
+ import { CLIP_TOOL_LIST } from "./clip-tools.ts";
34
+ import { PITCH_VOICE_NAMES, type PitchVoice } from "../audio/dsp/pitch.ts";
35
+ import {
36
+ analyzeTrackPitch,
37
+ guideNotesScore,
38
+ hzName,
39
+ PitchTargetError,
40
+ pitchReportLines,
41
+ } from "../commands/vocal-pitch.ts";
42
+
43
+ const trackIdSchema = {
44
+ type: "string",
45
+ description: "Target track id. Defaults to the focused track.",
46
+ maxLength: SCORE_LIMITS.maxIdLength,
47
+ };
48
+
49
+ /** The tool's track: `trackId` or the focused one (as tools.ts). */
50
+ function targetTrack(
51
+ args: Record<string, unknown>,
52
+ context: ToolContext,
53
+ ): string {
54
+ const trackId = args.trackId ?? context.focusedTrackId;
55
+ if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
56
+ throw new ToolArgumentError("trackId must be a short string");
57
+ if (!context.score.tracks.some((track) => track.id === trackId))
58
+ throw new ToolArgumentError(
59
+ `unknown track ${trackId}; create it with create_track first`,
60
+ );
61
+ return trackId;
62
+ }
63
+ import {
64
+ VOCODER_CARRIERS,
65
+ VOCODER_FOLLOWS,
66
+ VOCODER_PARAMS,
67
+ VOCODER_PRESET_NAMES,
68
+ isVocoderPreset,
69
+ vocoderParamName,
70
+ type VocoderCarrier,
71
+ type VocoderFollow,
72
+ type VocoderPresetName,
73
+ } from "../../core/vocoder.ts";
74
+ import {
75
+ applyVocoderCommand,
76
+ vocodeTrack,
77
+ vocoderCandidates,
78
+ vocoderCostHint,
79
+ vocoderEntryLines,
80
+ type VocoderValue,
81
+ } from "../commands/vocoder.ts";
82
+ import {
83
+ AUTOTUNE_FIELDS,
84
+ AUTOTUNE_PARAMS,
85
+ AUTOTUNE_PRESETS,
86
+ AUTOTUNE_TARGETS,
87
+ AUTOTUNE_VOICES,
88
+ type AutotunePreset,
89
+ } from "../../core/autotune.ts";
90
+ import {
91
+ applyAutotuneCommand,
92
+ type AutotuneCommand,
93
+ } from "../commands/autotune.ts";
94
+
95
+ /** A refused argument; named like tools.ts's so the model sees it. */
96
+ class AutotuneToolError extends Error {
97
+ constructor(message: string) {
98
+ super(message);
99
+ this.name = "ToolArgumentError";
100
+ }
101
+ }
102
+
103
+ /** An agent tool, optionally usable as a `preview_sound` candidate. */
104
+ export type VoiceTool = AgentTool & Readonly<{ previewable?: boolean }>;
105
+
106
+ /** clips: place_clip, edit_clip, set_lyrics. */
107
+ export const CLIPS_TOOLS: readonly VoiceTool[] = CLIP_TOOL_LIST;
108
+
109
+ /** Notes listed in an analyze_pitch result, at most. */
110
+ export const ANALYZE_PITCH_MAX_NOTES = 64;
111
+
112
+ type PitchToolArgs = { trackId: string; target?: string; voice?: PitchVoice };
113
+
114
+ function pitchToolArgs(
115
+ args: Record<string, unknown>,
116
+ focusedTrackId: string,
117
+ ): PitchToolArgs {
118
+ const trackId = args.trackId ?? focusedTrackId;
119
+ if (typeof trackId !== "string" || trackId.length === 0)
120
+ throw new ToolArgumentError("trackId must be a track id");
121
+ const out: PitchToolArgs = { trackId };
122
+ if (args.clip !== undefined) {
123
+ if (typeof args.clip !== "string" || args.clip.length === 0)
124
+ throw new ToolArgumentError(
125
+ "clip must be a clip id or sampler voice name",
126
+ );
127
+ out.target = args.clip;
128
+ }
129
+ if (args.voice !== undefined) {
130
+ if (!PITCH_VOICE_NAMES.includes(args.voice as PitchVoice))
131
+ throw new ToolArgumentError(
132
+ `voice is one of ${PITCH_VOICE_NAMES.join(", ")}`,
133
+ );
134
+ out.voice = args.voice as PitchVoice;
135
+ }
136
+ return out;
137
+ }
138
+
139
+ const pitchToolProperties = {
140
+ trackId: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
141
+ clip: {
142
+ type: "string",
143
+ description:
144
+ "Clip id or sampler voice on the track; default the first clip, else the first voice.",
145
+ },
146
+ voice: {
147
+ type: "string",
148
+ enum: PITCH_VOICE_NAMES,
149
+ description:
150
+ "Expected range: auto (70-1400 Hz, default) or bass, tenor, alto, soprano.",
151
+ },
152
+ };
153
+
154
+ function rethrow(error: unknown): never {
155
+ if (error instanceof PitchTargetError)
156
+ throw new ToolArgumentError(error.message);
157
+ throw error;
158
+ }
159
+
160
+ /** pitch: analyze_pitch, pitch_to_notes. */
161
+ export const PITCH_TOOLS: readonly VoiceTool[] = [
162
+ {
163
+ name: "analyze_pitch",
164
+ description:
165
+ "Read-only: track the pitch of a track's audio clip or sampler voice (default the focused track) and report the detected key, median pitch, range and the sung notes with their cents off pitch. Cached per file.",
166
+ parameters: {
167
+ type: "object",
168
+ properties: pitchToolProperties,
169
+ additionalProperties: false,
170
+ },
171
+ plan(args, context) {
172
+ const parsed = pitchToolArgs(args, context.focusedTrackId);
173
+ return {
174
+ kind: "action",
175
+ summary: `analyze_pitch ${parsed.trackId}`,
176
+ run: async (action) => {
177
+ const root = action.workspace?.root ?? process.cwd();
178
+ const report = await analyzeTrackPitch(
179
+ context.score,
180
+ parsed.trackId,
181
+ root,
182
+ parsed,
183
+ ).catch(rethrow);
184
+ const lines = pitchReportLines(report).slice(0, -1);
185
+ const shown = report.notes.slice(0, ANALYZE_PITCH_MAX_NOTES);
186
+ if (shown.length > 0) lines.push("notes (file seconds):");
187
+ for (const note of shown)
188
+ lines.push(
189
+ ` ${note.start.toFixed(2)}-${note.end.toFixed(2)} ${hzName(440 * 2 ** ((note.midi - 69) / 12))} ${note.cents >= 0 ? "+" : ""}${Math.round(note.cents)}c`,
190
+ );
191
+ if (report.notes.length > shown.length)
192
+ lines.push(` … ${report.notes.length - shown.length} more`);
193
+ return {
194
+ content: lines.join("\n"),
195
+ summary: `${report.trackId} · ${report.key ?? "key unclear"} · ${hzName(report.median)}`,
196
+ };
197
+ },
198
+ };
199
+ },
200
+ },
201
+ {
202
+ name: "pitch_to_notes",
203
+ description:
204
+ "Turn a track's sung or played audio (clip or sampler voice) into a new guide-notes track, one note per detected note, placed through the tempo map where the audio plays. The audio track is unchanged.",
205
+ parameters: {
206
+ type: "object",
207
+ properties: {
208
+ ...pitchToolProperties,
209
+ as: {
210
+ type: "string",
211
+ maxLength: SCORE_LIMITS.maxIdLength,
212
+ description: "Id for the new track; default <track>-notes.",
213
+ },
214
+ },
215
+ additionalProperties: false,
216
+ },
217
+ plan(args, context) {
218
+ const parsed = pitchToolArgs(args, context.focusedTrackId);
219
+ if (
220
+ args.as !== undefined &&
221
+ (typeof args.as !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(args.as))
222
+ )
223
+ throw new ToolArgumentError("as must be a track id");
224
+ const as = args.as as string | undefined;
225
+ return {
226
+ kind: "prepare",
227
+ summary: `pitch_to_notes ${parsed.trackId}`,
228
+ run: async (action) => {
229
+ const root = action.workspace?.root ?? process.cwd();
230
+ const report = await analyzeTrackPitch(
231
+ context.score,
232
+ parsed.trackId,
233
+ root,
234
+ parsed,
235
+ ).catch(rethrow);
236
+ if (report.notes.length === 0)
237
+ throw new ToolArgumentError(
238
+ `no notes found in ${report.trackId} · ${report.target.label}`,
239
+ );
240
+ let made: ReturnType<typeof guideNotesScore>;
241
+ try {
242
+ made = guideNotesScore(context.score, report, as ? { as } : {});
243
+ } catch (error) {
244
+ rethrow(error);
245
+ }
246
+ const track = made.next.tracks.find((t) => t.id === made.trackId)!;
247
+ const notes = made.next.notes.filter(
248
+ (note) => note.trackId === made.trackId,
249
+ );
250
+ return {
251
+ kind: "score",
252
+ operations: [
253
+ { type: "addTrack", track },
254
+ ...notes.map((note) => ({ type: "addNote" as const, note })),
255
+ ],
256
+ summary: `${made.count} guide notes from ${report.trackId} · ${report.target.label} on ${made.trackId}${report.key ? ` · ${report.key}` : ""}`,
257
+ trackId: made.trackId,
258
+ };
259
+ },
260
+ };
261
+ },
262
+ },
263
+ ];
264
+ const FORMANT_PRESETS = Object.keys(FX_PRESETS.formant ?? {});
265
+
266
+ /**
267
+ * formant: set_formant. The fx `formant` stage (core/fx.ts): shift moves the
268
+ * spectral envelope in semitones while the pitch stays, mix blends it.
269
+ */
270
+ export const FORMANT_TOOLS: readonly VoiceTool[] = [
271
+ {
272
+ name: "set_formant",
273
+ previewable: true,
274
+ description: `Shift a track's formants at constant pitch (the throat or gender knob; works on any source, voices most of all): shift -12..12 semitones (negative deeper or bigger, positive smaller or younger; 2-4 is natural, 7+ is a cartoon), mix 0..1 (default 1); preset ${FORMANT_PRESETS.join(" ")}; off removes it. Automatable as lanes formant-shift and formant-mix. The vowel filter is set_fx vowel (vowel, to, morph 0..1 morphs between two vowels; lane vowel-morph).`,
275
+ parameters: {
276
+ type: "object",
277
+ properties: {
278
+ trackId: trackIdSchema,
279
+ shift: { type: "number", minimum: -12, maximum: 12 },
280
+ mix: { type: "number", minimum: 0, maximum: 1 },
281
+ preset: { type: "string", enum: FORMANT_PRESETS },
282
+ off: { type: "boolean" },
283
+ },
284
+ additionalProperties: false,
285
+ },
286
+ plan(args, context) {
287
+ const trackId = targetTrack(args, context);
288
+ if ("vowel" in args) throw new ToolArgumentError(FORMANT_VOWEL_HINT);
289
+ const commands: FxCommand[] = [];
290
+ const others = (["shift", "mix", "preset"] as const).filter(
291
+ (key) => args[key] !== undefined,
292
+ );
293
+ if (args.off === true && others.length > 0)
294
+ throw new ToolArgumentError(
295
+ `off removes the formant stage; drop ${others.join(", ")} or off`,
296
+ );
297
+ // A preset sets shift; an explicit shift overrides it, and the summary says so.
298
+ const overridden =
299
+ args.preset !== undefined && args.shift !== undefined
300
+ ? ` (shift ${String(args.shift)} overrides preset ${String(args.preset)})`
301
+ : "";
302
+ if (args.off === true)
303
+ commands.push({ type: "fx-off", effect: "formant" });
304
+ else {
305
+ if (args.preset !== undefined) {
306
+ if (
307
+ typeof args.preset !== "string" ||
308
+ !FORMANT_PRESETS.includes(args.preset)
309
+ )
310
+ throw new ToolArgumentError(
311
+ `preset must be one of ${FORMANT_PRESETS.join(", ")}`,
312
+ );
313
+ commands.push({
314
+ type: "fx-preset",
315
+ effect: "formant",
316
+ preset: args.preset,
317
+ });
318
+ }
319
+ const values: Record<string, number> = {};
320
+ for (const key of ["shift", "mix"] as const) {
321
+ const value = args[key];
322
+ if (value === undefined) continue;
323
+ if (typeof value !== "number" || !Number.isFinite(value))
324
+ throw new ToolArgumentError(`${key} must be a number`);
325
+ values[key] = value;
326
+ }
327
+ if (Object.keys(values).length > 0)
328
+ commands.push({ type: "fx-set", effect: "formant", values });
329
+ // An empty call turns it on (shift 0, mix 1): a neutral start.
330
+ if (commands.length === 0)
331
+ commands.push({ type: "fx-on", effect: "formant" });
332
+ }
333
+ let score = context.score;
334
+ let message = "";
335
+ for (const command of commands) {
336
+ const result = applyFxCommand(score, trackId, command);
337
+ if (!result.ok) throw new ToolArgumentError(result.message);
338
+ if (result.next) score = result.next;
339
+ message = result.message;
340
+ }
341
+ const track = context.score.tracks.find((t) => t.id === trackId)!;
342
+ const values = effectValues(
343
+ score.tracks.find((t) => t.id === trackId),
344
+ "formant",
345
+ );
346
+ return {
347
+ kind: "score",
348
+ operations: [
349
+ {
350
+ type: "updateTrack",
351
+ trackId,
352
+ patch: effectPatch(track, "formant", values ?? null),
353
+ },
354
+ ],
355
+ trackId,
356
+ summary: `${trackId} ${message}${overridden}`,
357
+ };
358
+ },
359
+ },
360
+ ];
361
+ /** sing: set_sing, set_vowels. */
362
+ export const SING_TOOLS: readonly VoiceTool[] = [
363
+ {
364
+ name: "set_sing",
365
+ previewable: true,
366
+ description: `The built-in singing voice (a synthetic LF glottal source through Klatt formants; no recorded or cloned voice). preset (${SING_PRESET_NAMES.join(" ")}) switches the voice and keeps overrides; params sets ${Object.keys(SING_PARAMS).join(" ")}, null returns one to the preset; voices 2..8 is an ensemble (choir); vowel is a e i o u or a morph a>o; drone (a note name like D3 or a MIDI number) turns on throat singing: khoomei, sygyt (whistle) and kargyraa (sub-octave growl) pick a harmonic of the drone per note. A throat preset on a track with no notes writes a short demo melody (8 notes an octave above the drone); write your own notes instead when you have a melody. reset clears overrides; off removes the voice. Notes sing their vowel (set_vowels) or their lyric's vowel. Turns the track into the sing engine.`,
367
+ parameters: {
368
+ type: "object",
369
+ properties: {
370
+ trackId: trackIdSchema,
371
+ preset: { type: "string", enum: [...SING_PRESET_NAMES] },
372
+ reset: { type: "boolean" },
373
+ off: { type: "boolean" },
374
+ params: {
375
+ type: "object",
376
+ additionalProperties: {
377
+ type: ["number", "string", "array", "null"],
378
+ },
379
+ },
380
+ },
381
+ additionalProperties: false,
382
+ },
383
+ plan(args, context) {
384
+ const trackId = targetTrack(args, context);
385
+ const commands: SingCommand[] = [];
386
+ if (args.off === true) commands.push({ type: "sing-off" });
387
+ if (args.reset === true) commands.push({ type: "sing-reset" });
388
+ let preset: (typeof SING_PRESET_NAMES)[number] | undefined;
389
+ if (args.preset !== undefined) {
390
+ if (typeof args.preset !== "string" || !isSingPreset(args.preset))
391
+ throw new ToolArgumentError(
392
+ `preset must be one of ${SING_PRESET_NAMES.join(", ")}`,
393
+ );
394
+ preset = args.preset;
395
+ }
396
+ const values: Record<string, SingValue> = {};
397
+ if (args.params !== undefined) {
398
+ if (
399
+ typeof args.params !== "object" ||
400
+ args.params === null ||
401
+ Array.isArray(args.params)
402
+ )
403
+ throw new ToolArgumentError("params must be an object");
404
+ for (const [key, value] of Object.entries(args.params)) {
405
+ const name = singParamName(key);
406
+ if (!name)
407
+ throw new ToolArgumentError(
408
+ `sing has no parameter ${key} (${Object.keys(SING_PARAMS).join(" ")})`,
409
+ );
410
+ if (
411
+ value !== null &&
412
+ typeof value !== "number" &&
413
+ typeof value !== "string" &&
414
+ !(
415
+ Array.isArray(value) &&
416
+ value.length === 2 &&
417
+ value.every((item) => typeof item === "number")
418
+ )
419
+ )
420
+ throw new ToolArgumentError(
421
+ `${key} must be a number, a string or [lo, hi]`,
422
+ );
423
+ values[name] = value as SingValue;
424
+ }
425
+ }
426
+ if (preset !== undefined || Object.keys(values).length > 0)
427
+ commands.push({
428
+ type: "sing-set",
429
+ ...(preset !== undefined ? { preset } : {}),
430
+ values,
431
+ });
432
+ if (commands.length === 0) {
433
+ // An empty call turns the track into the sing engine.
434
+ const current = context.score.tracks.find((t) => t.id === trackId);
435
+ commands.push({
436
+ type: "sing-set",
437
+ preset:
438
+ current?.instrument === SING_INSTRUMENT && current.sing?.preset
439
+ ? current.sing.preset
440
+ : "aah",
441
+ values: {},
442
+ });
443
+ }
444
+ let score = context.score;
445
+ const messages: string[] = [];
446
+ for (const command of commands) {
447
+ const result = applySingCommand(score, trackId, command);
448
+ if (!result.ok) throw new ToolArgumentError(result.message);
449
+ if (result.next) score = result.next;
450
+ messages.push(result.message);
451
+ }
452
+ const next = score.tracks.find((t) => t.id === trackId)!;
453
+ // A throat preset on an empty track writes its demo line.
454
+ const before = new Set(context.score.notes.map((note) => note.id));
455
+ const added = score.notes.filter((note) => !before.has(note.id));
456
+ return {
457
+ kind: "score",
458
+ operations: [
459
+ {
460
+ type: "updateTrack",
461
+ trackId,
462
+ patch: { instrument: next.instrument, sing: next.sing ?? null },
463
+ },
464
+ ...added.map((note) => ({ type: "addNote" as const, note })),
465
+ ],
466
+ trackId,
467
+ summary: `${trackId} ${messages.at(-1)}`,
468
+ };
469
+ },
470
+ },
471
+ {
472
+ name: "set_vowels",
473
+ previewable: true,
474
+ description:
475
+ "Set the sung vowel of a sing track's notes (set_sing). vowels: a list cycled over the notes in time order, each a e i o u (also ah eh ee oh oo) or a morph like a>u that travels through the note; noteIds limits it to those notes; vowels [] or null clears them so notes sing their lyric's vowel or the track's vowel.",
476
+ parameters: {
477
+ type: "object",
478
+ properties: {
479
+ trackId: trackIdSchema,
480
+ vowels: {
481
+ type: ["array", "null"],
482
+ items: { type: "string", maxLength: 16 },
483
+ maxItems: 64,
484
+ },
485
+ noteIds: {
486
+ type: "array",
487
+ items: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
488
+ maxItems: 128,
489
+ },
490
+ },
491
+ required: ["vowels"],
492
+ additionalProperties: false,
493
+ },
494
+ plan(args, context) {
495
+ const trackId = targetTrack(args, context);
496
+ const raw = args.vowels;
497
+ if (raw !== null && !Array.isArray(raw))
498
+ throw new ToolArgumentError("vowels must be a list or null");
499
+ const vowels: string[] = [];
500
+ for (const item of raw ?? []) {
501
+ if (typeof item !== "string")
502
+ throw new ToolArgumentError("each vowel must be a string");
503
+ try {
504
+ vowels.push(normalizeVowel(item));
505
+ } catch (error) {
506
+ throw new ToolArgumentError(
507
+ error instanceof Error ? error.message : `bad vowel ${item}`,
508
+ );
509
+ }
510
+ }
511
+ let noteIds: string[] | undefined;
512
+ if (args.noteIds !== undefined) {
513
+ if (!Array.isArray(args.noteIds))
514
+ throw new ToolArgumentError("noteIds must be a list");
515
+ noteIds = args.noteIds.map((id) => {
516
+ const note =
517
+ typeof id === "string"
518
+ ? context.score.notes.find((n) => n.id === id)
519
+ : undefined;
520
+ if (!note || note.trackId !== trackId)
521
+ throw new ToolArgumentError(
522
+ `unknown note ${String(id)} on ${trackId}`,
523
+ );
524
+ return id as string;
525
+ });
526
+ }
527
+ const notes = context.score.notes
528
+ .filter((note) =>
529
+ noteIds ? noteIds.includes(note.id) : note.trackId === trackId,
530
+ )
531
+ .sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
532
+ if (notes.length === 0)
533
+ throw new ToolArgumentError(`${trackId} has no notes`);
534
+ if (vowels.length === 0)
535
+ return {
536
+ kind: "score",
537
+ operations: notes.map((note) => ({
538
+ type: "updateNote" as const,
539
+ noteId: note.id,
540
+ patch: { vowel: null },
541
+ })),
542
+ trackId,
543
+ summary: `${trackId} vowels cleared on ${notes.length} notes`,
544
+ };
545
+ const { next } = assignVowels(context.score, trackId, vowels, noteIds);
546
+ const after = new Map(next.notes.map((note) => [note.id, note.vowel]));
547
+ return {
548
+ kind: "score",
549
+ operations: notes.map((note) => ({
550
+ type: "updateNote" as const,
551
+ noteId: note.id,
552
+ patch: { vowel: after.get(note.id) ?? null },
553
+ })),
554
+ trackId,
555
+ summary: `${trackId} sings ${vowels.join(" ")} over ${notes.length} notes`,
556
+ };
557
+ },
558
+ },
559
+ ];
560
+ /** `set_vocoder.params` keys: exactly VOCODER_PARAMS. */
561
+ export const VOCODER_TOOL_PARAM_KEYS: readonly string[] = Object.freeze(
562
+ Object.keys(VOCODER_PARAMS),
563
+ );
564
+
565
+ const vocoderTrackId = {
566
+ type: "string",
567
+ description: "Target track id. Defaults to the focused track.",
568
+ maxLength: SCORE_LIMITS.maxIdLength,
569
+ };
570
+
571
+ function knownTrack(
572
+ value: unknown,
573
+ fallback: string,
574
+ tracks: readonly { id: string }[],
575
+ label: string,
576
+ ): string {
577
+ const id = value ?? fallback;
578
+ if (typeof id !== "string" || id.length > SCORE_LIMITS.maxIdLength)
579
+ throw new ToolArgumentError(`${label} must be a short string`);
580
+ if (!tracks.some((track) => track.id === id))
581
+ throw new ToolArgumentError(`unknown track ${id}`);
582
+ return id;
583
+ }
584
+
585
+ function presetArg(value: unknown): VocoderPresetName | undefined {
586
+ if (value === undefined) return undefined;
587
+ if (typeof value !== "string" || !isVocoderPreset(value))
588
+ throw new ToolArgumentError(
589
+ `preset must be one of ${VOCODER_PRESET_NAMES.join(", ")}`,
590
+ );
591
+ return value;
592
+ }
593
+
594
+ /** vocoder: set_vocoder, vocode. */
595
+ export const VOCODER_TOOLS: readonly VoiceTool[] = [
596
+ {
597
+ name: "set_vocoder",
598
+ previewable: true,
599
+ description:
600
+ "Put a vocoder on a track (the carrier: its synth, sampler or the built-in `vocoder` instrument) or change it. To vocode a vocal, create_track with instrument vocoder, then set_vocoder src <vocal>. src is the modulator track, usually a vocal (id or name slug). preset (classic robot talkbox choir glass whisper smear lofi) keeps overrides; params sets VOCODER_PARAMS keys, null returns one to the preset; reset drops every override (keeps src and preset); off removes the vocoder. The modulator is heard even when muted. Returns a cost hint and the modulator's license. Vocode only the user's own or licensed audio.",
601
+ parameters: {
602
+ type: "object",
603
+ additionalProperties: false,
604
+ properties: {
605
+ trackId: vocoderTrackId,
606
+ src: { type: "string", maxLength: 64 },
607
+ preset: { type: "string", enum: [...VOCODER_PRESET_NAMES] },
608
+ reset: { type: "boolean" },
609
+ off: { type: "boolean" },
610
+ params: {
611
+ type: "object",
612
+ description: `VOCODER_PARAMS: ${Object.keys(VOCODER_PARAMS).join(" ")}; gate takes "auto"; null resets one`,
613
+ additionalProperties: {
614
+ type: ["number", "string", "boolean", "null"],
615
+ },
616
+ },
617
+ },
618
+ },
619
+ plan(args, context) {
620
+ const trackId = knownTrack(
621
+ args.trackId,
622
+ context.focusedTrackId,
623
+ context.score.tracks,
624
+ "trackId",
625
+ );
626
+ let score = context.score;
627
+ const messages: string[] = [];
628
+ const run = (command: Parameters<typeof applyVocoderCommand>[2]) => {
629
+ const result = applyVocoderCommand(score, trackId, command);
630
+ if (!result.ok) throw new ToolArgumentError(result.message);
631
+ if (result.next) score = result.next;
632
+ messages.push(result.message);
633
+ };
634
+ if (args.off === true) run({ type: "vocoder-off" });
635
+ else {
636
+ const values: Record<string, VocoderValue> = {};
637
+ if (args.params !== undefined) {
638
+ if (
639
+ typeof args.params !== "object" ||
640
+ args.params === null ||
641
+ Array.isArray(args.params)
642
+ )
643
+ throw new ToolArgumentError("params must be an object");
644
+ for (const [key, value] of Object.entries(args.params)) {
645
+ const name = vocoderParamName(key);
646
+ if (!name)
647
+ throw new ToolArgumentError(
648
+ `vocoder has no parameter ${key.slice(0, 32)} (${VOCODER_TOOL_PARAM_KEYS.join(" ")})`,
649
+ );
650
+ if (
651
+ value !== null &&
652
+ typeof value !== "number" &&
653
+ typeof value !== "string" &&
654
+ typeof value !== "boolean"
655
+ )
656
+ throw new ToolArgumentError(
657
+ `${key} must be a number, string, boolean or null`,
658
+ );
659
+ values[name] = value;
660
+ }
661
+ }
662
+ if (args.reset === true) run({ type: "vocoder-reset" });
663
+ const preset = presetArg(args.preset);
664
+ if (args.src !== undefined && typeof args.src !== "string")
665
+ throw new ToolArgumentError("src must be a track id or name");
666
+ const src = args.src as string | undefined;
667
+ const current = score.tracks.find((t) => t.id === trackId)!;
668
+ if (
669
+ preset ||
670
+ src !== undefined ||
671
+ Object.keys(values).length > 0 ||
672
+ (!current.vocoder && args.reset !== true)
673
+ ) {
674
+ if (!current.vocoder && src === undefined) {
675
+ const candidates = vocoderCandidates(score, trackId);
676
+ if (candidates.length !== 1)
677
+ throw new ToolArgumentError(
678
+ candidates.length === 0
679
+ ? vocoderEntryLines().join(" ")
680
+ : `src is required: one of ${candidates.map((t) => t.id).join(", ")}`,
681
+ );
682
+ }
683
+ run({
684
+ type: "vocoder-set",
685
+ ...(preset ? { preset } : {}),
686
+ src:
687
+ src ??
688
+ current.vocoder?.src ??
689
+ vocoderCandidates(score, trackId)[0]!.id,
690
+ values,
691
+ });
692
+ }
693
+ }
694
+ const next = score.tracks.find((t) => t.id === trackId)!;
695
+ const cost = next.vocoder ? ` · ${vocoderCostHint(next.vocoder)}` : "";
696
+ return {
697
+ kind: "score",
698
+ operations: [
699
+ {
700
+ type: "updateTrack",
701
+ trackId,
702
+ patch: { vocoder: next.vocoder ?? null },
703
+ },
704
+ ],
705
+ trackId,
706
+ summary: `${trackId} ${messages.at(-1) ?? "vocoder unchanged"}${cost}`,
707
+ };
708
+ },
709
+ },
710
+ {
711
+ name: "vocode",
712
+ previewable: true,
713
+ hidden: true,
714
+ description:
715
+ "Use set_vocoder. One step: create a vocoder track driven by a vocal (or any) track, following the song's chords (or a drone on the key's root when there are none) unless follow is given. Mutes the source unless keepSource (the vocoder still hears a muted source). Echoes the source's license. Vocode only the user's own or licensed audio.",
716
+ parameters: {
717
+ type: "object",
718
+ additionalProperties: false,
719
+ required: ["src"],
720
+ properties: {
721
+ src: vocoderTrackId,
722
+ preset: { type: "string", enum: [...VOCODER_PRESET_NAMES] },
723
+ name: { type: "string", maxLength: 48 },
724
+ carrier: { type: "string", enum: [...VOCODER_CARRIERS] },
725
+ follow: { type: "string", enum: [...VOCODER_FOLLOWS] },
726
+ keepSource: { type: "boolean" },
727
+ },
728
+ },
729
+ plan(args, context) {
730
+ const src = knownTrack(args.src, "", context.score.tracks, "src");
731
+ const enumArg = <T extends string>(
732
+ value: unknown,
733
+ values: readonly string[],
734
+ label: string,
735
+ ): T | undefined => {
736
+ if (value === undefined) return undefined;
737
+ if (typeof value !== "string" || !values.includes(value))
738
+ throw new ToolArgumentError(
739
+ `${label} must be one of ${values.join(", ")}`,
740
+ );
741
+ return value as T;
742
+ };
743
+ if (args.name !== undefined && typeof args.name !== "string")
744
+ throw new ToolArgumentError("name must be a string");
745
+ const preset = presetArg(args.preset);
746
+ const carrier = enumArg<VocoderCarrier>(
747
+ args.carrier,
748
+ VOCODER_CARRIERS,
749
+ "carrier",
750
+ );
751
+ const follow = enumArg<VocoderFollow>(
752
+ args.follow,
753
+ VOCODER_FOLLOWS,
754
+ "follow",
755
+ );
756
+ const result = vocodeTrack(context.score, src, {
757
+ ...(preset ? { preset } : {}),
758
+ ...(typeof args.name === "string" ? { name: args.name } : {}),
759
+ ...(carrier ? { carrier } : {}),
760
+ ...(follow ? { follow } : {}),
761
+ ...(args.keepSource === true ? { keepSource: true } : {}),
762
+ });
763
+ if (!result.ok || !result.next || !result.trackId)
764
+ throw new ToolArgumentError(result.message);
765
+ const track = result.next.tracks.find((t) => t.id === result.trackId)!;
766
+ const muted = result.next.tracks.find((t) => t.id === src)!.muted;
767
+ const before = context.score.tracks.find((t) => t.id === src)!.muted;
768
+ return {
769
+ kind: "score",
770
+ operations: [
771
+ { type: "addTrack", track },
772
+ ...(muted !== before
773
+ ? [
774
+ {
775
+ type: "updateTrack" as const,
776
+ trackId: src,
777
+ patch: { muted: true },
778
+ },
779
+ ]
780
+ : []),
781
+ ],
782
+ trackId: result.trackId,
783
+ summary: `${result.message.split("\n").join(" ")} · ${vocoderCostHint(track.vocoder!)}`,
784
+ };
785
+ },
786
+ },
787
+ ];
788
+ /** autotune: autotune_vocal. */
789
+ export const AUTOTUNE_TOOLS: readonly VoiceTool[] = [
790
+ {
791
+ name: "autotune_vocal",
792
+ previewable: true,
793
+ description:
794
+ "Pitch correction on a track's audio (its clips and sampler voices), gentle to hard. preset: hard (instant stepped notes), robot (stepped on every tuning step), warble (hard with synthetic vibrato), trap (fast, glossy), pop (default: polished but sung), natural, gentle (keeps vibrato), guided (follows guide notes), locked (follows notes exactly). params override one field, null returns it to the preset: to scale|chromatic|chord|notes (scale: key or the song key and tuning, so maqam, raga and n-EDO work; chromatic without a key; chord: the chord timeline; notes: `from` or the track's own notes), from <trackId>, key (e.g. 'D bayati'), speed ms (0 instant), relax 0..1, hold ms, flex 0..100 (Antares-style: bends wider than flex cents pass), glide ms (0..500), amount 0..1, vib Hz, vibmod semitones, center 0..1, drift 0..1, voice auto|bass|tenor|alto|soprano. reset drops overrides; off removes it. Unsure of the key? analyze_pitch first. Never fetch an artist's vocal to imitate.",
795
+ parameters: {
796
+ type: "object",
797
+ properties: {
798
+ trackId: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
799
+ preset: { type: "string", enum: [...AUTOTUNE_PRESETS] },
800
+ reset: { type: "boolean" },
801
+ off: { type: "boolean" },
802
+ params: {
803
+ type: "object",
804
+ properties: {
805
+ to: { type: ["string", "null"], enum: [...AUTOTUNE_TARGETS, null] },
806
+ from: { type: ["string", "null"] },
807
+ key: { type: ["string", "null"] },
808
+ voice: {
809
+ type: ["string", "null"],
810
+ enum: [...AUTOTUNE_VOICES, null],
811
+ },
812
+ ...Object.fromEntries(
813
+ AUTOTUNE_PARAMS.map((param) => [
814
+ param.name,
815
+ {
816
+ type: ["number", "null"],
817
+ minimum: param.min,
818
+ maximum: param.max,
819
+ },
820
+ ]),
821
+ ),
822
+ },
823
+ additionalProperties: false,
824
+ },
825
+ },
826
+ additionalProperties: false,
827
+ },
828
+ plan(args, context) {
829
+ const trackId = args.trackId ?? context.focusedTrackId;
830
+ if (typeof trackId !== "string" || trackId.length === 0)
831
+ throw new AutotuneToolError("trackId must be a track id");
832
+ if (!context.score.tracks.some((track) => track.id === trackId))
833
+ throw new AutotuneToolError(
834
+ `unknown track ${trackId}; create it with create_track first`,
835
+ );
836
+ const commands: AutotuneCommand[] = [];
837
+ if (args.off === true) commands.push({ type: "autotune-off" });
838
+ else {
839
+ if (args.reset === true) commands.push({ type: "autotune-reset" });
840
+ let preset: AutotunePreset | undefined;
841
+ if (args.preset !== undefined) {
842
+ if (
843
+ typeof args.preset !== "string" ||
844
+ !(AUTOTUNE_PRESETS as readonly string[]).includes(args.preset)
845
+ )
846
+ throw new AutotuneToolError(
847
+ `preset must be one of ${AUTOTUNE_PRESETS.join(", ")}`,
848
+ );
849
+ preset = args.preset as AutotunePreset;
850
+ }
851
+ const values: Record<string, number | string | null> = {};
852
+ if (args.params !== undefined) {
853
+ if (
854
+ typeof args.params !== "object" ||
855
+ args.params === null ||
856
+ Array.isArray(args.params)
857
+ )
858
+ throw new AutotuneToolError("params must be an object");
859
+ for (const [key, value] of Object.entries(args.params)) {
860
+ if (
861
+ !(AUTOTUNE_FIELDS as readonly string[]).includes(key) ||
862
+ key === "preset"
863
+ )
864
+ throw new AutotuneToolError(
865
+ `autotune has no field ${key} (${AUTOTUNE_FIELDS.filter((f) => f !== "preset").join(" ")})`,
866
+ );
867
+ if (
868
+ value !== null &&
869
+ typeof value !== "number" &&
870
+ typeof value !== "string"
871
+ )
872
+ throw new AutotuneToolError(`${key} must be a number or string`);
873
+ // glide reads in ms here, as /autotune and the menu show it.
874
+ values[key] =
875
+ key === "glide" && typeof value === "number"
876
+ ? value / 1000
877
+ : value;
878
+ }
879
+ }
880
+ if (preset || Object.keys(values).length > 0 || commands.length === 0)
881
+ commands.push({
882
+ type: "autotune-set",
883
+ ...(preset ? { preset } : {}),
884
+ values,
885
+ });
886
+ }
887
+ let score = context.score;
888
+ const messages: string[] = [];
889
+ for (const command of commands) {
890
+ const result = applyAutotuneCommand(score, trackId, command);
891
+ if (!result.ok) throw new AutotuneToolError(result.message);
892
+ if (result.next) score = result.next;
893
+ messages.push(result.message);
894
+ }
895
+ const next = score.tracks.find((track) => track.id === trackId)!;
896
+ return {
897
+ kind: "score",
898
+ operations: [
899
+ {
900
+ type: "updateTrack",
901
+ trackId,
902
+ patch: { autotune: next.autotune ?? null },
903
+ },
904
+ ],
905
+ trackId,
906
+ summary: `${trackId} ${messages.at(-1)}`,
907
+ };
908
+ },
909
+ },
910
+ ];
911
+
912
+ /** Every voice tool, in lane order. */
913
+ export const VOICE_TOOLS: readonly VoiceTool[] = [
914
+ ...CLIPS_TOOLS,
915
+ ...PITCH_TOOLS,
916
+ ...FORMANT_TOOLS,
917
+ ...SING_TOOLS,
918
+ ...VOCODER_TOOLS,
919
+ ...AUTOTUNE_TOOLS,
920
+ ];
921
+
922
+ /** Voice tools a `preview_sound` candidate may use. */
923
+ export const VOICE_PREVIEWABLE_TOOLS: readonly string[] = VOICE_TOOLS.filter(
924
+ (tool) => tool.previewable === true,
925
+ ).map((tool) => tool.name);