@hraness/dawg 0.4.1 → 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.
- package/CHANGELOG.md +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- 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[]);
|
package/src/agent/models.ts
CHANGED
|
@@ -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 ≈
|
|
490
|
-
* (capped at 12 KiB), user request ≈ 0.1 KB → ≈
|
|
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 ≈
|
|
496
|
+
* So ≈ 22,100 input and ≈ 600 output tokens per prompt.
|
|
497
497
|
*/
|
|
498
498
|
export const TYPICAL_PROMPT = Object.freeze({
|
|
499
|
-
inputTokens:
|
|
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
|
|
148
|
-
const
|
|
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 {
|
|
174
|
+
return {
|
|
175
|
+
type: "add-note",
|
|
176
|
+
pitch,
|
|
177
|
+
start,
|
|
178
|
+
duration,
|
|
179
|
+
velocity: 0.8,
|
|
180
|
+
...(offset !== 0 ? { cents: offset } : {}),
|
|
181
|
+
};
|
|
157
182
|
}
|
package/src/agent/planner.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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);
|