@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Agent tools for sample packs: `list_packs`, `search_sounds`, `use_sound`.
3
+ * `use_sound` resolves and fetches the sound first (a `prepare` plan), then
4
+ * commits ordinary score operations so it is one undo step like any edit.
5
+ */
6
+ import { SCORE_LIMITS, type TrackScore } from "../../core/score.ts";
7
+ import {
8
+ PackError,
9
+ PackStore,
10
+ banksOf,
11
+ type Manifest,
12
+ } from "../audio/packs.ts";
13
+
14
+ /** `RolandTR909 (TR909)`: banks with their Strudel nickname, if any. */
15
+ async function kitsWithNicknames(
16
+ packs: PackStore,
17
+ pack: string,
18
+ manifest: Manifest,
19
+ ): Promise<string[]> {
20
+ const aliases = await packs.bankAliases(pack);
21
+ return banksOf(manifest).map((bank) => {
22
+ const nickname = aliases.nicknames.get(bank);
23
+ return nickname ? `${bank} (${nickname})` : bank;
24
+ });
25
+ }
26
+ import { useSound } from "../commands/pack.ts";
27
+ import { pickWavetable, wavetableOperation } from "../commands/wavetable.ts";
28
+ import {
29
+ WARP_MODES,
30
+ WAVETABLE_PARAMS,
31
+ wavetableOf,
32
+ type ScoreOperation,
33
+ type TrackWavetable,
34
+ type WavetableParam,
35
+ } from "../../core/score.ts";
36
+ import type { AgentTool } from "./tools.ts";
37
+
38
+ /** Thrown for arguments the pack tools refuse. */
39
+ export class PackToolError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "PackToolError";
43
+ }
44
+ }
45
+
46
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
47
+
48
+ function store(context: { packs?: PackStore }): PackStore {
49
+ return context.packs ?? defaultStore();
50
+ }
51
+
52
+ let shared: PackStore | undefined;
53
+ function defaultStore(): PackStore {
54
+ shared ??= new PackStore();
55
+ return shared;
56
+ }
57
+
58
+ function bounded(value: unknown): string {
59
+ const text = JSON.stringify(value);
60
+ return text.length <= 24_000 ? text : `${text.slice(0, 24_000)}…`;
61
+ }
62
+
63
+ const wavetableParamSchema = Object.fromEntries(
64
+ (Object.keys(WAVETABLE_PARAMS) as WavetableParam[]).map((name) => [
65
+ name,
66
+ {
67
+ type: "number",
68
+ minimum: WAVETABLE_PARAMS[name][0],
69
+ maximum: WAVETABLE_PARAMS[name][1],
70
+ },
71
+ ]),
72
+ );
73
+
74
+ async function wavetableOps(
75
+ packs: PackStore,
76
+ score: TrackScore,
77
+ trackId: string,
78
+ table: string | undefined,
79
+ fields: Partial<Record<WavetableParam, number>> & { warpmode?: string },
80
+ projectRoot?: string,
81
+ ): Promise<{ operations: ScoreOperation[]; summary: string }> {
82
+ const track = score.tracks.find((item) => item.id === trackId);
83
+ let base: TrackWavetable = track
84
+ ? wavetableOf(track)
85
+ : { table: { src: "builtin:basic" } };
86
+ let summary = "wavetable";
87
+ if (table) {
88
+ // pickWavetable pins the table; take its settings, keep the score as is.
89
+ const picked = await pickWavetable(
90
+ packs,
91
+ score,
92
+ trackId,
93
+ table,
94
+ projectRoot,
95
+ );
96
+ summary = picked.summary;
97
+ const op = picked.operation;
98
+ const patched =
99
+ op.type === "addTrack"
100
+ ? op.track.wavetable
101
+ : op.type === "updateTrack"
102
+ ? op.patch.wavetable
103
+ : undefined;
104
+ if (patched) base = patched;
105
+ }
106
+ const changed = Object.entries(fields);
107
+ if (changed.length)
108
+ summary += ` · ${changed.map(([key, value]) => `${key} ${String(value)}`).join(" · ")}`;
109
+ return {
110
+ operations: [
111
+ wavetableOperation(score, trackId, {
112
+ ...base,
113
+ ...fields,
114
+ } as TrackWavetable),
115
+ ],
116
+ summary,
117
+ };
118
+ }
119
+
120
+ export const PACK_TOOLS: readonly AgentTool[] = Object.freeze([
121
+ {
122
+ name: "list_packs",
123
+ description:
124
+ "List the sample packs dawg can use (Strudel-format manifests: drum machines, Dirt-Samples, VCSL, General MIDI soundfonts, …) with license and, for one pack, its kits and sounds.",
125
+ parameters: {
126
+ type: "object",
127
+ properties: {
128
+ pack: {
129
+ type: "string",
130
+ maxLength: 64,
131
+ description: "A pack name to list its kits and sounds.",
132
+ },
133
+ },
134
+ additionalProperties: false,
135
+ },
136
+ plan(args) {
137
+ const name = typeof args.pack === "string" ? args.pack.trim() : "";
138
+ return {
139
+ kind: "action",
140
+ summary: name ? `pack ${name}` : "list packs",
141
+ run: async (context) => {
142
+ const packs = store(context);
143
+ if (!name) {
144
+ const list = await packs.list();
145
+ return {
146
+ content: bounded({
147
+ ok: true,
148
+ packs: list.map((pack) => ({
149
+ name: pack.name,
150
+ title: pack.title,
151
+ license: pack.license,
152
+ })),
153
+ hint: "search_sounds finds sounds; use_sound puts one on a track",
154
+ }),
155
+ summary: `${list.length} packs`,
156
+ };
157
+ }
158
+ const info = await packs.info(name);
159
+ if (!info)
160
+ throw new PackToolError(`no pack named ${name.slice(0, 40)}`);
161
+ const manifest = await packs.manifest(info.name);
162
+ const sounds = [...manifest.sounds]
163
+ .slice(0, 300)
164
+ .map(([sound, entry]) =>
165
+ entry.kind === "zones"
166
+ ? `${sound} (keyed, ${entry.zones.length} notes)`
167
+ : `${sound} (${entry.files.length})`,
168
+ );
169
+ return {
170
+ content: bounded({
171
+ ok: true,
172
+ pack: info.name,
173
+ license: info.license,
174
+ ...(info.attribution ? { attribution: info.attribution } : {}),
175
+ kits: await kitsWithNicknames(packs, info.name, manifest),
176
+ sounds,
177
+ total: manifest.sounds.size,
178
+ }),
179
+ summary: `${info.name} · ${manifest.sounds.size} sounds`,
180
+ };
181
+ },
182
+ };
183
+ },
184
+ },
185
+ {
186
+ name: "search_sounds",
187
+ description:
188
+ "Search pack sounds by name (e.g. 'bd', '909 hh', 'piano', 'gm guitar'). Returns pack:<pack>/<sound> refs for use_sound; keyed sounds are pitched instruments.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ query: { type: "string", minLength: 1, maxLength: 80 },
193
+ pack: { type: "string", maxLength: 64 },
194
+ limit: { type: "integer", minimum: 1, maximum: 100 },
195
+ },
196
+ required: ["query"],
197
+ additionalProperties: false,
198
+ },
199
+ plan(args) {
200
+ if (typeof args.query !== "string" || !args.query.trim())
201
+ throw new PackToolError("query must be a non-empty string");
202
+ const query = args.query.trim().slice(0, 80);
203
+ const pack = typeof args.pack === "string" ? args.pack.trim() : undefined;
204
+ const limit =
205
+ typeof args.limit === "number" && Number.isInteger(args.limit)
206
+ ? Math.max(1, Math.min(100, args.limit))
207
+ : 30;
208
+ return {
209
+ kind: "action",
210
+ summary: `search sounds ${query}`,
211
+ run: async (context) => {
212
+ const matches = await store(context).search(query, {
213
+ ...(pack ? { pack } : {}),
214
+ limit,
215
+ });
216
+ return {
217
+ content: bounded({ ok: true, matches }),
218
+ summary: `${matches.length} sounds for ${query}`,
219
+ };
220
+ },
221
+ };
222
+ },
223
+ },
224
+ {
225
+ name: "use_sound",
226
+ description:
227
+ 'Put a pack sound on a track through the sampler. sound is <pack>/<sound>[:<n>] (n picks a file, like Strudel s("bd:3")), a kit/bank name or Strudel bank nickname (909, 808, linn, TR909, tr808, sp12, dmx, RolandTR909, tidal-drum-machines/RolandTR707) to load a whole drum kit, or a keyed instrument such as gm/gm_acoustic_grand_piano or piano/piano. The file is fetched and pinned by sha256 so renders stay reproducible. Drum hits on the track keep playing the matching kit voice.',
228
+ parameters: {
229
+ type: "object",
230
+ properties: {
231
+ sound: { type: "string", minLength: 1, maxLength: 200 },
232
+ trackId: {
233
+ type: "string",
234
+ maxLength: SCORE_LIMITS.maxIdLength,
235
+ description:
236
+ "Target track id (created when missing). Defaults to the focused track.",
237
+ },
238
+ voice: {
239
+ type: "string",
240
+ maxLength: 32,
241
+ description: "Oneshot voice name (default from the sound, e.g. bd).",
242
+ },
243
+ mode: { type: "string", enum: ["auto", "keyed", "oneshot"] },
244
+ },
245
+ required: ["sound"],
246
+ additionalProperties: false,
247
+ },
248
+ plan(args, context) {
249
+ if (typeof args.sound !== "string" || !args.sound.trim())
250
+ throw new PackToolError("sound must be <pack>/<sound> or a kit name");
251
+ const sound = args.sound.trim();
252
+ const trackId =
253
+ typeof args.trackId === "string" && args.trackId
254
+ ? args.trackId
255
+ : context.focusedTrackId;
256
+ if (!ID_PATTERN.test(trackId))
257
+ throw new PackToolError(
258
+ "trackId must be 1-64 letters, digits, dot, dash, or underscore",
259
+ );
260
+ const voice = typeof args.voice === "string" ? args.voice : undefined;
261
+ const mode =
262
+ args.mode === "keyed" || args.mode === "oneshot" ? args.mode : "auto";
263
+ return {
264
+ kind: "prepare",
265
+ summary: `use ${sound}`,
266
+ run: async (action) => {
267
+ try {
268
+ const result = await useSound(
269
+ store(action),
270
+ context.score,
271
+ trackId,
272
+ sound,
273
+ {
274
+ ...(voice ? { voice } : {}),
275
+ mode,
276
+ },
277
+ );
278
+ return {
279
+ kind: "score",
280
+ operations: result.operations,
281
+ summary: result.summary,
282
+ trackId: result.trackId,
283
+ };
284
+ } catch (error) {
285
+ if (error instanceof PackError)
286
+ throw new PackToolError(error.message);
287
+ throw error;
288
+ }
289
+ },
290
+ };
291
+ },
292
+ },
293
+ {
294
+ name: "set_wavetable",
295
+ description:
296
+ "Make a track a wavetable synth and shape it (Strudel names). table: basic (sine>tri>saw>square), pwm, formant, harmonics (offline), wt_digital:0-4, wt_vgame:0-10 (Strudel uzu-wavetables), pack:<pack>/<sound>[:n], or a project table from make_wavetable (tracks/<slug>/wavetables/<name>.wav); omit to keep. wt: position 0..1. wtenv/wtattack/wtdecay/wtsustain/wtrelease: position envelope (amount -1..1, seconds). wtrate Hz/wtdepth: position LFO. warp+warpmode bend the phase. wtphaserand: start phase spread. Automate position with set_automation wt.",
297
+ parameters: {
298
+ type: "object",
299
+ properties: {
300
+ trackId: {
301
+ type: "string",
302
+ maxLength: SCORE_LIMITS.maxIdLength,
303
+ description:
304
+ "Target track (created when missing). Defaults to the focused track.",
305
+ },
306
+ table: { type: "string", minLength: 1, maxLength: 200 },
307
+ ...wavetableParamSchema,
308
+ warpmode: { type: "string", enum: [...WARP_MODES] },
309
+ },
310
+ additionalProperties: false,
311
+ },
312
+ plan(args, context) {
313
+ const trackId =
314
+ typeof args.trackId === "string" && args.trackId
315
+ ? args.trackId
316
+ : context.focusedTrackId;
317
+ if (!ID_PATTERN.test(trackId))
318
+ throw new PackToolError(
319
+ "trackId must be 1-64 letters, digits, dot, dash, or underscore",
320
+ );
321
+ const table =
322
+ typeof args.table === "string" && args.table.trim()
323
+ ? args.table.trim()
324
+ : undefined;
325
+ const fields: Partial<Record<WavetableParam, number>> & {
326
+ warpmode?: string;
327
+ } = {};
328
+ for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
329
+ const value = args[name];
330
+ if (value === undefined) continue;
331
+ const [min, max] = WAVETABLE_PARAMS[name];
332
+ if (
333
+ typeof value !== "number" ||
334
+ !Number.isFinite(value) ||
335
+ value < min ||
336
+ value > max
337
+ )
338
+ throw new PackToolError(`${name} must be ${min}..${max}`);
339
+ fields[name] = value;
340
+ }
341
+ if (args.warpmode !== undefined) {
342
+ if (!WARP_MODES.includes(args.warpmode as never))
343
+ throw new PackToolError(`warpmode must be ${WARP_MODES.join(", ")}`);
344
+ fields.warpmode = args.warpmode as string;
345
+ }
346
+ return {
347
+ kind: "prepare",
348
+ summary: table ? `wavetable ${table}` : "shape wavetable",
349
+ run: async (action) => {
350
+ try {
351
+ const result = await wavetableOps(
352
+ store(action),
353
+ context.score,
354
+ trackId,
355
+ table,
356
+ fields,
357
+ action.workspace?.root,
358
+ );
359
+ return { kind: "score", ...result, trackId };
360
+ } catch (error) {
361
+ if (error instanceof PackError)
362
+ throw new PackToolError(error.message);
363
+ throw error;
364
+ }
365
+ },
366
+ };
367
+ },
368
+ },
369
+ ]);
@@ -1,14 +1,19 @@
1
+ import { normalizeSynth } from "../../core/synth.ts";
2
+ import { normalizeFx } from "../../core/fx.ts";
1
3
  import {
2
4
  SCORE_LIMITS,
3
- AUTOMATION_LANES,
5
+ automationRange,
4
6
  isAutomationParameter,
5
7
  normalizeDelay,
6
8
  normalizeFilter,
7
9
  normalizeReverb,
10
+ normalizeSampler,
11
+ normalizeRhythm,
8
12
  scoreFromJSON,
9
13
  type ScoreOperation,
10
14
  } from "../../core/score.ts";
11
15
  import { DRUM_VOICES } from "../../core/drums.ts";
16
+ import { synthKit } from "../../core/kits.ts";
12
17
 
13
18
  const MAX_RESPONSE_BYTES = 64 * 1024;
14
19
  const MAX_OPERATIONS = 32;
@@ -91,6 +96,41 @@ function parseOperation(value: unknown): ScoreOperation {
91
96
  ) {
92
97
  return { type: "clearTrack", trackId: value.trackId };
93
98
  }
99
+ if (
100
+ value.type === "removeTrack" &&
101
+ typeof value.trackId === "string" &&
102
+ value.trackId.length <= 64
103
+ ) {
104
+ return { type: "removeTrack", trackId: value.trackId };
105
+ }
106
+ if (
107
+ value.type === "moveTrack" &&
108
+ typeof value.trackId === "string" &&
109
+ value.trackId.length <= 64 &&
110
+ typeof value.index === "number" &&
111
+ Number.isInteger(value.index) &&
112
+ value.index >= 0 &&
113
+ value.index < SCORE_LIMITS.maxTracks
114
+ ) {
115
+ return { type: "moveTrack", trackId: value.trackId, index: value.index };
116
+ }
117
+ if (
118
+ value.type === "setKey" &&
119
+ (value.key === null ||
120
+ (typeof value.key === "string" &&
121
+ value.key.length <= SCORE_LIMITS.maxNameLength))
122
+ ) {
123
+ return { type: "setKey", key: value.key };
124
+ }
125
+ if (
126
+ value.type === "setMeter" &&
127
+ typeof value.beatsPerBar === "number" &&
128
+ Number.isInteger(value.beatsPerBar) &&
129
+ value.beatsPerBar >= 1 &&
130
+ value.beatsPerBar <= SCORE_LIMITS.maxBeatsPerBar
131
+ ) {
132
+ return { type: "setMeter", beatsPerBar: value.beatsPerBar };
133
+ }
94
134
  if (
95
135
  value.type === "updateTrack" &&
96
136
  typeof value.trackId === "string" &&
@@ -115,6 +155,19 @@ function parseOperation(value: unknown): ScoreOperation {
115
155
  safe.delay = normalizeDelay(patch.delay) ?? null;
116
156
  if (patch.reverb !== undefined)
117
157
  safe.reverb = normalizeReverb(patch.reverb) ?? null;
158
+ if (patch.sampler !== undefined)
159
+ safe.sampler = normalizeSampler(patch.sampler) ?? null;
160
+ if (patch.rhythm !== undefined)
161
+ safe.rhythm = normalizeRhythm(patch.rhythm, value.trackId) ?? null;
162
+ // Only synth kit names pass; anything else leaves the kit unchanged.
163
+ if (patch.kit === null) safe.kit = null;
164
+ else if (typeof patch.kit === "string") {
165
+ const kit = synthKit(patch.kit);
166
+ if (kit) safe.kit = kit.name;
167
+ }
168
+ if (patch.fx !== undefined) safe.fx = normalizeFx(patch.fx) ?? null;
169
+ if (patch.synth !== undefined)
170
+ safe.synth = normalizeSynth(patch.synth) ?? null;
118
171
  return { type: "updateTrack", trackId: value.trackId, patch: safe };
119
172
  }
120
173
  if (
@@ -125,7 +178,7 @@ function parseOperation(value: unknown): ScoreOperation {
125
178
  Array.isArray(value.points) &&
126
179
  value.points.length <= SCORE_LIMITS.maxAutomationPoints
127
180
  ) {
128
- const { min: minValue, max: maxValue } = AUTOMATION_LANES[value.parameter];
181
+ const { min: minValue, max: maxValue } = automationRange(value.parameter);
129
182
  const points = value.points.map((candidate) => {
130
183
  if (!isRecord(candidate))
131
184
  throw new Error("agent automation point is malformed");