@hraness/dawg 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Agent tools for the song master: `set_master` (score) edits the chain and
3
+ * loudness target through the same `nextMaster` path as the `master` prompt
4
+ * command, and `measure_mix` (read-only) renders the song and reports
5
+ * loudness (BS.1770 / EBU R 128), true peak, spectral balance and stereo
6
+ * correlation, so the agent can check a mix before and after mastering.
7
+ */
8
+
9
+ import {
10
+ LOUDNESS_TARGET_NAMES,
11
+ LOUDNESS_TARGETS,
12
+ MASTER_LIMITS,
13
+ MASTER_PRESETS,
14
+ MASTER_SPECS,
15
+ MASTER_UNITS,
16
+ describeMaster,
17
+ isLoudnessTargetName,
18
+ isMasterUnit,
19
+ normalizeMaster,
20
+ type MasterUnit,
21
+ type SongMaster,
22
+ } from "../../core/master.ts";
23
+ import { FxValidationError } from "../../core/params.ts";
24
+ import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
25
+ import {
26
+ exportSampleRate,
27
+ measureScore,
28
+ type ScoreMeasurement,
29
+ } from "../audio/measure.ts";
30
+ import { nextMaster, type MasterCommand } from "../commands/master.ts";
31
+ import type { AgentTool } from "./tools.ts";
32
+ import type { PreviewHost } from "./preview-tool.ts";
33
+
34
+ class MasterToolError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "ToolArgumentError";
38
+ }
39
+ }
40
+
41
+ const TARGET_LIST = LOUDNESS_TARGET_NAMES.map(
42
+ (name) => `${name} ${LOUDNESS_TARGETS[name].lufs}`,
43
+ ).join(", ");
44
+
45
+ const UNIT_ARGS = Object.fromEntries(
46
+ MASTER_UNITS.map((unit) => [
47
+ unit,
48
+ {
49
+ description: `${MASTER_SPECS[unit].doc}; params ${Object.keys(MASTER_SPECS[unit].params).join(" ")}`,
50
+ anyOf: [
51
+ { type: "boolean" },
52
+ { type: "string", enum: Object.keys(MASTER_PRESETS[unit]) },
53
+ { type: "object" },
54
+ ],
55
+ },
56
+ ]),
57
+ );
58
+
59
+ function unitCommands(unit: MasterUnit, value: unknown): MasterCommand[] {
60
+ if (value === undefined) return [];
61
+ if (value === true || value === false)
62
+ return [{ type: "master-unit", unit, on: value }];
63
+ if (typeof value === "string") {
64
+ if (!Object.hasOwn(MASTER_PRESETS[unit], value))
65
+ throw new MasterToolError(
66
+ `${unit} preset must be one of ${Object.keys(MASTER_PRESETS[unit]).join(", ")}`,
67
+ );
68
+ return [{ type: "master-preset", unit, preset: value }];
69
+ }
70
+ if (typeof value !== "object" || value === null || Array.isArray(value))
71
+ throw new MasterToolError(
72
+ `${unit} must be true, false, a preset name or params`,
73
+ );
74
+ const values: Record<string, number | string | boolean> = {};
75
+ for (const [key, raw] of Object.entries(value)) {
76
+ if (
77
+ typeof raw !== "number" &&
78
+ typeof raw !== "string" &&
79
+ typeof raw !== "boolean"
80
+ )
81
+ throw new MasterToolError(`${unit}.${key} must be a number or string`);
82
+ values[key] = raw;
83
+ }
84
+ return [{ type: "master-set", unit, values }];
85
+ }
86
+
87
+ function targetCommand(value: unknown): MasterCommand | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (value === null || value === "off")
90
+ return { type: "master-target", lufs: null };
91
+ if (typeof value === "string") {
92
+ if (!isLoudnessTargetName(value))
93
+ throw new MasterToolError(
94
+ `target must be LUFS or one of ${LOUDNESS_TARGET_NAMES.join(", ")}`,
95
+ );
96
+ return {
97
+ type: "master-target",
98
+ lufs: LOUDNESS_TARGETS[value].lufs,
99
+ name: value,
100
+ };
101
+ }
102
+ if (typeof value !== "number" || !Number.isFinite(value))
103
+ throw new MasterToolError("target must be LUFS, a name or null");
104
+ // Like the prompt's `master target 14`: loudness targets are negative.
105
+ return { type: "master-target", lufs: -Math.abs(value) };
106
+ }
107
+
108
+ function round(value: number, digits = 1): number | null {
109
+ if (!Number.isFinite(value)) return null;
110
+ const scale = 10 ** digits;
111
+ return Math.round(value * scale) / scale;
112
+ }
113
+
114
+ /** The measure_mix result: plain numbers (null for silence), no series. */
115
+ export function measurementJSON(
116
+ measured: ScoreMeasurement,
117
+ master: SongMaster | undefined,
118
+ ): Record<string, unknown> {
119
+ const { mix } = measured;
120
+ const loudness = mix.loudness;
121
+ const notes: string[] = [];
122
+ if (!Number.isFinite(loudness.integrated))
123
+ notes.push("silent: nothing to measure");
124
+ else {
125
+ // The limiter's own ceiling when one is set; otherwise -1 dBTP, or -2
126
+ // for a master louder than -14 LUFS (lossy encodes of loud, dense
127
+ // material make inter-sample overs).
128
+ const set = master?.limiter?.ceiling;
129
+ const safe = loudness.integrated > -14 ? -2 : -1;
130
+ const ceiling = typeof set === "number" ? set : safe;
131
+ if (loudness.truePeak > ceiling + 0.1)
132
+ notes.push(
133
+ typeof set === "number"
134
+ ? `true peak above the ${ceiling} dBTP ceiling`
135
+ : `true peak above ${safe} dBTP: lossy encodes may clip (keep -1 dBTP, -2 for masters louder than -14 LUFS)`,
136
+ );
137
+ if (mix.correlation < 0)
138
+ notes.push("negative correlation: parts cancel in mono");
139
+ if (mix.bands.sub > -3)
140
+ notes.push("sub band dominates; check the low end on small speakers");
141
+ }
142
+ if (measured.master?.target !== undefined && !measured.master.reached)
143
+ notes.push(
144
+ measured.master.integrated < measured.master.target
145
+ ? "target missed: the limiter has hit its plateau; add master tape (preset crush) or a lower glue threshold before it"
146
+ : "target missed: the mix is louder than the target allows",
147
+ );
148
+ return {
149
+ integrated: round(loudness.integrated),
150
+ shortTermMax: round(loudness.shortTermMax),
151
+ momentaryMax: round(loudness.momentaryMax),
152
+ range: round(loudness.range),
153
+ truePeak: round(loudness.truePeak),
154
+ samplePeak: round(loudness.samplePeak),
155
+ plr: round(mix.plr),
156
+ bands: Object.fromEntries(
157
+ Object.entries(mix.bands).map(([name, db]) => [name, round(db)]),
158
+ ),
159
+ correlation: round(mix.correlation, 2),
160
+ sideDb: round(mix.sideDb),
161
+ seconds: round(measured.seconds, 2),
162
+ sampleRate: measured.sampleRate,
163
+ loop: measured.loop,
164
+ master: master
165
+ ? {
166
+ chain: describeMaster(master),
167
+ ...(measured.master
168
+ ? {
169
+ gainDb: round(measured.master.gainDb),
170
+ ...(measured.master.target === undefined
171
+ ? {}
172
+ : {
173
+ target: measured.master.target,
174
+ reached: measured.master.reached,
175
+ }),
176
+ }
177
+ : {}),
178
+ }
179
+ : null,
180
+ ...(notes.length ? { notes } : {}),
181
+ };
182
+ }
183
+
184
+ /** Measure with the window's decoded samples when it has them. */
185
+ async function measureWith(
186
+ preview: PreviewHost | undefined,
187
+ score: TrackScore,
188
+ sampleRate: number,
189
+ ): Promise<ScoreMeasurement> {
190
+ return preview?.measure
191
+ ? await preview.measure(score, { sampleRate })
192
+ : measureScore(score, { sampleRate });
193
+ }
194
+
195
+ export const MASTER_TOOLS = Object.freeze([
196
+ {
197
+ name: "set_master",
198
+ description: `Edit the song master (after every track and bus) and its loudness target. Units run eq, glue, tape, width, limiter; each takes false (remove), true (defaults), a preset or {param: value}; omitted units stay. A named target (${TARGET_LIST}) also sets the limiter. off:true removes the master.`,
199
+ parameters: {
200
+ type: "object",
201
+ properties: {
202
+ ...UNIT_ARGS,
203
+ target: {
204
+ description: `Integrated LUFS (${MASTER_LIMITS.minTarget} to ${MASTER_LIMITS.maxTarget}), a name, or null to clear. The limiter's drive reaches it; without a limiter, gain stops at ${MASTER_LIMITS.safeCeiling} dBTP.`,
205
+ anyOf: [
206
+ { type: "number" },
207
+ { type: "string", enum: [...LOUDNESS_TARGET_NAMES] },
208
+ { type: "null" },
209
+ ],
210
+ },
211
+ off: {
212
+ type: "boolean",
213
+ description: "Remove the whole master.",
214
+ },
215
+ },
216
+ additionalProperties: false,
217
+ },
218
+ plan(args, context) {
219
+ const commands: MasterCommand[] = [];
220
+ if (args.off === true) commands.push({ type: "master-off" });
221
+ for (const unit of MASTER_UNITS)
222
+ commands.push(...unitCommands(unit, args[unit]));
223
+ const target = targetCommand(args.target);
224
+ if (target) commands.push(target);
225
+ for (const key of Object.keys(args))
226
+ if (key !== "off" && key !== "target" && !isMasterUnit(key))
227
+ throw new MasterToolError(`unknown argument ${key}`);
228
+ if (commands.length === 0)
229
+ throw new MasterToolError(
230
+ "pass a unit (eq, glue, tape, width, limiter), target or off",
231
+ );
232
+ let master: SongMaster | undefined = context.score.master;
233
+ try {
234
+ for (const command of commands)
235
+ master = normalizeMaster(nextMaster(master, command));
236
+ } catch (error) {
237
+ if (
238
+ error instanceof FxValidationError ||
239
+ error instanceof ScoreValidationError
240
+ )
241
+ throw new MasterToolError(error.message);
242
+ throw error;
243
+ }
244
+ return {
245
+ kind: "score",
246
+ operations: [{ type: "setMaster", master: master ?? null }],
247
+ summary: `master · ${describeMaster(master)}`.slice(0, 160),
248
+ };
249
+ },
250
+ },
251
+ {
252
+ name: "measure_mix",
253
+ description:
254
+ "Render and measure the song (read-only): integrated, short-term and momentary LUFS, loudness range, true peak, PLR, band balance (dB share), stereo correlation and side level, after the master. bypass_master:true measures before it.",
255
+ parameters: {
256
+ type: "object",
257
+ properties: {
258
+ bypass_master: {
259
+ type: "boolean",
260
+ description: "Measure without the master.",
261
+ },
262
+ },
263
+ additionalProperties: false,
264
+ },
265
+ plan(args, context) {
266
+ if (
267
+ args.bypass_master !== undefined &&
268
+ typeof args.bypass_master !== "boolean"
269
+ )
270
+ throw new MasterToolError("bypass_master must be a boolean");
271
+ const bypass = args.bypass_master === true && !!context.score.master;
272
+ const score = bypass ? context.score.withMaster(null) : context.score;
273
+ return {
274
+ kind: "action",
275
+ summary: bypass
276
+ ? "measured the mix before the master"
277
+ : "measured the mix",
278
+ async run(action) {
279
+ // The bypassed reading stays at the mastered song's rate, so the
280
+ // before/after comparison is not a resampling artefact.
281
+ const measured = await measureWith(
282
+ action.preview,
283
+ score,
284
+ exportSampleRate(context.score),
285
+ );
286
+ const result = measurementJSON(measured, score.master);
287
+ const integrated = result.integrated;
288
+ return {
289
+ content: JSON.stringify(result),
290
+ summary:
291
+ integrated === null
292
+ ? "measured · silent"
293
+ : `measured · ${integrated} LUFS · ${result.truePeak} dBTP`,
294
+ };
295
+ },
296
+ };
297
+ },
298
+ },
299
+ ] satisfies AgentTool[]);
@@ -486,17 +486,17 @@ export async function loadLiveCatalog(
486
486
  * and `chatTools()` serialized, plus `compositionBrief` of a 3-track, 48-note
487
487
  * loop; see models.test.ts, which keeps these in step with the code):
488
488
  *
489
- * - system prompt ≈ 2.8 KB, tool schemas ≈ 21.9 KB, brief ≈ 1.2 KB typical
490
- * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 26 KB ≈ 6,500 tokens
489
+ * - system prompt ≈ 4.3 KB, tool schemas ≈ 38 KB, brief ≈ 1.6 KB typical
490
+ * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 44 KB ≈ 11,000 tokens
491
491
  * per request at ~4 bytes per token;
492
492
  * - about 2 requests per prompt (tool calls, then a short reply), the second
493
493
  * also carrying the first step's tool calls and results (≈ 0.6 KB);
494
494
  * - output ≈ 450 tokens of tool-call arguments and ≈ 150 of reply.
495
495
  *
496
- * So ≈ 13,200 input and ≈ 600 output tokens per prompt.
496
+ * So ≈ 22,100 input and ≈ 600 output tokens per prompt.
497
497
  */
498
498
  export const TYPICAL_PROMPT = Object.freeze({
499
- inputTokens: 13_200,
499
+ inputTokens: 22_100,
500
500
  outputTokens: 600,
501
501
  });
502
502
 
package/src/agent/ops.ts CHANGED
@@ -11,6 +11,8 @@ export type AgentOperation =
11
11
  start: number;
12
12
  duration: number;
13
13
  velocity: number;
14
+ /** Static offset from an `E4-14c` pitch; absent is 0. */
15
+ cents?: number;
14
16
  }
15
17
  | { type: "remove-note"; noteId: string }
16
18
  | {
@@ -21,6 +23,8 @@ export type AgentOperation =
21
23
  duration?: number;
22
24
  pitch?: number;
23
25
  velocity?: number;
26
+ /** 0 clears the offset. */
27
+ cents?: number;
24
28
  };
25
29
  }
26
30
  | { type: "set-tempo"; tempoBpm: number }
@@ -139,13 +143,27 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
139
143
  noteId: velocity[1]!,
140
144
  patch: { velocity: Number(velocity[2]) },
141
145
  };
146
+ const cents = text.match(
147
+ /^(?:cents|detune)\s+(?:note\s+)?([a-z0-9._-]{1,64})\s+([+-]?\d+(?:\.\d+)?)c?$/,
148
+ );
149
+ if (cents) {
150
+ const value = Number(cents[2]);
151
+ if (Math.abs(value) <= 1200)
152
+ return {
153
+ type: "update-note",
154
+ noteId: cents[1]!,
155
+ patch: { cents: value },
156
+ };
157
+ }
142
158
  const match = text.match(
143
- /^(?:add|put)\s+(?:note\s+)?([a-g](?:#|b)?-?\d+)\s+(?:at\s+)?(\d+(?:\.\d+)?)\s*(?:for|dur|duration)?\s*(\d+(?:\.\d+)?)?$/,
159
+ /^(?:add|put)\s+(?:note\s+)?([a-g](?:#|b)?-?\d+)([+-]\d+(?:\.\d+)?c)?\s+(?:at\s+)?(\d+(?:\.\d+)?)\s*(?:for|dur|duration)?\s*(\d+(?:\.\d+)?)?$/,
144
160
  );
145
161
  if (!match) return undefined;
146
162
  const pitch = pitchToMidi(match[1] ?? "c4");
147
- const start = Number(match[2] ?? 0);
148
- const duration = Number(match[3] ?? 1);
163
+ const offset = match[2] ? Number(match[2].slice(0, -1)) : 0;
164
+ const start = Number(match[3] ?? 0);
165
+ const duration = Number(match[4] ?? 1);
166
+ if (!(Math.abs(offset) <= 1200)) return undefined;
149
167
  if (
150
168
  !Number.isFinite(pitch) ||
151
169
  !Number.isFinite(start) ||
@@ -153,5 +171,12 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
153
171
  duration <= 0
154
172
  )
155
173
  return undefined;
156
- return { type: "add-note", pitch, start, duration, velocity: 0.8 };
174
+ return {
175
+ type: "add-note",
176
+ pitch,
177
+ start,
178
+ duration,
179
+ velocity: 0.8,
180
+ ...(offset !== 0 ? { cents: offset } : {}),
181
+ };
157
182
  }
@@ -1,5 +1,7 @@
1
1
  import { normalizeSynth } from "../../core/synth.ts";
2
+ import { normalizeSongTime, normalizeTrackTime } from "../../core/tempo.ts";
2
3
  import { normalizeFx } from "../../core/fx.ts";
4
+ import { normalizeMaster } from "../../core/master.ts";
3
5
  import {
4
6
  SCORE_LIMITS,
5
7
  automationRange,
@@ -10,8 +12,12 @@ import {
10
12
  normalizeSampler,
11
13
  normalizeRhythm,
12
14
  scoreFromJSON,
15
+ type NotePatch,
16
+ type FormEntry,
13
17
  type ScoreOperation,
18
+ type Section,
14
19
  } from "../../core/score.ts";
20
+ import { NOTE_EXPRESSION_FIELDS } from "../../core/expression.ts";
15
21
  import { DRUM_VOICES } from "../../core/drums.ts";
16
22
  import { synthKit } from "../../core/kits.ts";
17
23
 
@@ -131,6 +137,34 @@ function parseOperation(value: unknown): ScoreOperation {
131
137
  ) {
132
138
  return { type: "setMeter", beatsPerBar: value.beatsPerBar };
133
139
  }
140
+ if (value.type === "setTime") {
141
+ // The reducer re-validates; this only bounds the shape.
142
+ const time = value.time === null ? null : normalizeSongTime(value.time);
143
+ return { type: "setTime", time: time ?? null };
144
+ }
145
+ // The master reuses its bounded validator; null removes it.
146
+ if (value.type === "setMaster" && value.master !== undefined)
147
+ return { type: "setMaster", master: normalizeMaster(value.master) ?? null };
148
+ if (
149
+ value.type === "setSections" &&
150
+ Array.isArray(value.sections) &&
151
+ value.sections.length <= SCORE_LIMITS.maxSections &&
152
+ (value.form === undefined ||
153
+ (Array.isArray(value.form) &&
154
+ value.form.length <= SCORE_LIMITS.maxFormEntries)) &&
155
+ (value.loopSection === undefined ||
156
+ value.loopSection === null ||
157
+ (typeof value.loopSection === "string" &&
158
+ value.loopSection.length <= SCORE_LIMITS.maxSectionNameLength))
159
+ ) {
160
+ // Shapes are validated by the reducer (TrackScore normalizes sections).
161
+ return {
162
+ type: "setSections",
163
+ sections: value.sections as readonly Section[],
164
+ form: (value.form ?? []) as readonly FormEntry[],
165
+ loopSection: (value.loopSection ?? null) as string | null,
166
+ };
167
+ }
134
168
  if (
135
169
  value.type === "updateTrack" &&
136
170
  typeof value.trackId === "string" &&
@@ -168,6 +202,8 @@ function parseOperation(value: unknown): ScoreOperation {
168
202
  if (patch.fx !== undefined) safe.fx = normalizeFx(patch.fx) ?? null;
169
203
  if (patch.synth !== undefined)
170
204
  safe.synth = normalizeSynth(patch.synth) ?? null;
205
+ if (patch.time !== undefined)
206
+ safe.time = normalizeTrackTime(patch.time) ?? null;
171
207
  return { type: "updateTrack", trackId: value.trackId, patch: safe };
172
208
  }
173
209
  if (
@@ -210,7 +246,7 @@ function parseOperation(value: unknown): ScoreOperation {
210
246
  isRecord(value.patch)
211
247
  ) {
212
248
  const patch = value.patch;
213
- const safe: Record<string, number> = {};
249
+ const safe: Record<string, unknown> = {};
214
250
  for (const key of [
215
251
  "startTick",
216
252
  "durationTicks",
@@ -220,7 +256,14 @@ function parseOperation(value: unknown): ScoreOperation {
220
256
  if (typeof patch[key] === "number" && Number.isFinite(patch[key]))
221
257
  safe[key] = patch[key];
222
258
  }
223
- return { type: "updateNote", noteId: value.noteId, patch: safe };
259
+ // Expression fields pass through; the reducer validates them.
260
+ for (const key of NOTE_EXPRESSION_FIELDS)
261
+ if (key in patch) safe[key] = patch[key];
262
+ return {
263
+ type: "updateNote",
264
+ noteId: value.noteId,
265
+ patch: safe as NotePatch,
266
+ };
224
267
  }
225
268
  if (value.type === "addNote" && isRecord(value.note)) {
226
269
  const note = value.note;
@@ -22,6 +22,7 @@ import {
22
22
  type SoundStats,
23
23
  } from "../audio/preview.ts";
24
24
  import { renderScorePcm, type RenderedAudio } from "../audio/wav.ts";
25
+ import type { ScoreMeasurement } from "../audio/measure.ts";
25
26
  import type {
26
27
  ActionContext,
27
28
  AgentTool,
@@ -51,6 +52,14 @@ export type PreviewHost = Readonly<{
51
52
  render?: (score: TrackScore) => Promise<RenderedAudio> | RenderedAudio;
52
53
  /** Play the snippet once; returns false when nothing could sound. */
53
54
  play?: (audio: RenderedAudio) => boolean | Promise<boolean>;
55
+ /**
56
+ * Render and measure for measure_mix (the host adds decoded samples), at
57
+ * `sampleRate` when given and the score's export rate otherwise.
58
+ */
59
+ measure?: (
60
+ score: TrackScore,
61
+ options?: Readonly<{ sampleRate?: number }>,
62
+ ) => Promise<ScoreMeasurement>;
54
63
  }>;
55
64
 
56
65
  /** Thrown for a candidate the tool refuses; nothing is rendered. */
@@ -217,22 +226,31 @@ export function previewSoundTool(
217
226
  : after.region;
218
227
  const render =
219
228
  action.preview?.render ?? ((value) => renderScorePcm(value));
220
- const snippet = async (value: TrackScore) => {
229
+ const snippet = async (value: TrackScore, masterGainDb?: number) => {
221
230
  const preview = previewScore(value, trackId, {
222
231
  context: inContext,
223
232
  region,
233
+ ...(masterGainDb === undefined ? {} : { masterGainDb }),
224
234
  });
225
235
  if (!preview) return undefined;
226
236
  const audio = await render(preview.score);
227
237
  return { audio, stats: analyzePcm(audio.pcm, audio.sampleRate) };
228
238
  };
229
- const next = await snippet(candidate.score);
230
- if (!next) throw new PreviewToolError(`unknown track ${trackId}`);
231
239
  const before =
232
240
  changes.length > 0 &&
233
241
  context.score.tracks.some((track) => track.id === trackId)
234
242
  ? await snippet(context.score)
235
243
  : undefined;
244
+ // The after snippet plays the master at the drive the before one
245
+ // used, so a level change is not normalized away by the target
246
+ // (unless the change is to the master itself).
247
+ const fixedGain = changes.some(
248
+ (change) => change.tool === "set_master",
249
+ )
250
+ ? undefined
251
+ : before?.audio.master?.gainDb;
252
+ const next = await snippet(candidate.score, fixedGain);
253
+ if (!next) throw new PreviewToolError(`unknown track ${trackId}`);
236
254
  let played = false;
237
255
  if (play && action.preview?.play)
238
256
  played = await action.preview.play(next.audio);