@hraness/dawg 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -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 +29 -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 +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -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/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -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/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- 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/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -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 +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- 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/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- 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 +459 -43
- 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 ≈ 7 KB, tool schemas ≈ 51 KB, brief ≈ 1.6 KB typical
|
|
490
|
+
* (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 59 KB ≈ 14,800 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 ≈ 29,800 input and ≈ 600 output tokens per prompt.
|
|
497
497
|
*/
|
|
498
498
|
export const TYPICAL_PROMPT = Object.freeze({
|
|
499
|
-
inputTokens:
|
|
499
|
+
inputTokens: 29_800,
|
|
500
500
|
outputTokens: 600,
|
|
501
501
|
});
|
|
502
502
|
|
package/src/agent/ops.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { resolveInstrumentWord } from "../../core/instruments.ts";
|
|
1
2
|
import { pitchToMidi } from "../../core/pitch.ts";
|
|
3
|
+
import {
|
|
4
|
+
instrumentPatchForWord,
|
|
5
|
+
type TrackModal,
|
|
6
|
+
} from "../../core/resonators.ts";
|
|
2
7
|
import { SCORE_LIMITS } from "../../core/score.ts";
|
|
3
8
|
|
|
4
9
|
export { pitchToMidi };
|
|
@@ -11,6 +16,8 @@ export type AgentOperation =
|
|
|
11
16
|
start: number;
|
|
12
17
|
duration: number;
|
|
13
18
|
velocity: number;
|
|
19
|
+
/** Static offset from an `E4-14c` pitch; absent is 0. */
|
|
20
|
+
cents?: number;
|
|
14
21
|
}
|
|
15
22
|
| { type: "remove-note"; noteId: string }
|
|
16
23
|
| {
|
|
@@ -21,6 +28,8 @@ export type AgentOperation =
|
|
|
21
28
|
duration?: number;
|
|
22
29
|
pitch?: number;
|
|
23
30
|
velocity?: number;
|
|
31
|
+
/** 0 clears the offset. */
|
|
32
|
+
cents?: number;
|
|
24
33
|
};
|
|
25
34
|
}
|
|
26
35
|
| { type: "set-tempo"; tempoBpm: number }
|
|
@@ -30,11 +39,16 @@ export type AgentOperation =
|
|
|
30
39
|
type: "track";
|
|
31
40
|
patch: {
|
|
32
41
|
instrument?: string;
|
|
42
|
+
string?: { preset: string };
|
|
43
|
+
/** 0.6: a modal preset word (`instrument vibes`) sets this too. */
|
|
44
|
+
modal?: TrackModal;
|
|
33
45
|
muted?: boolean;
|
|
34
46
|
volume?: number;
|
|
35
47
|
pan?: number;
|
|
36
48
|
name?: string;
|
|
37
49
|
};
|
|
50
|
+
/** The instrument word as typed (a guitar alias also loads its rig). */
|
|
51
|
+
word?: string;
|
|
38
52
|
}
|
|
39
53
|
| {
|
|
40
54
|
type: "automation";
|
|
@@ -70,8 +84,14 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
|
|
|
70
84
|
const instrument = text.match(
|
|
71
85
|
/^(?:instrument|sound|voice)\s+([a-z0-9._ -]{1,64})$/,
|
|
72
86
|
);
|
|
73
|
-
if (instrument)
|
|
74
|
-
|
|
87
|
+
if (instrument) {
|
|
88
|
+
const word = instrument[1]!.trim();
|
|
89
|
+
return {
|
|
90
|
+
type: "track",
|
|
91
|
+
patch: instrumentPatch(word),
|
|
92
|
+
...(resolveInstrumentWord(word)?.fx ? { word } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
75
95
|
if (/^(?:mute|silence)\b/.test(text))
|
|
76
96
|
return { type: "track", patch: { muted: true } };
|
|
77
97
|
if (/^(?:unmute|unsilence)\b/.test(text))
|
|
@@ -139,13 +159,27 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
|
|
|
139
159
|
noteId: velocity[1]!,
|
|
140
160
|
patch: { velocity: Number(velocity[2]) },
|
|
141
161
|
};
|
|
162
|
+
const cents = text.match(
|
|
163
|
+
/^(?:cents|detune)\s+(?:note\s+)?([a-z0-9._-]{1,64})\s+([+-]?\d+(?:\.\d+)?)c?$/,
|
|
164
|
+
);
|
|
165
|
+
if (cents) {
|
|
166
|
+
const value = Number(cents[2]);
|
|
167
|
+
if (Math.abs(value) <= 1200)
|
|
168
|
+
return {
|
|
169
|
+
type: "update-note",
|
|
170
|
+
noteId: cents[1]!,
|
|
171
|
+
patch: { cents: value },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
142
174
|
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+)?)?$/,
|
|
175
|
+
/^(?: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
176
|
);
|
|
145
177
|
if (!match) return undefined;
|
|
146
178
|
const pitch = pitchToMidi(match[1] ?? "c4");
|
|
147
|
-
const
|
|
148
|
-
const
|
|
179
|
+
const offset = match[2] ? Number(match[2].slice(0, -1)) : 0;
|
|
180
|
+
const start = Number(match[3] ?? 0);
|
|
181
|
+
const duration = Number(match[4] ?? 1);
|
|
182
|
+
if (!(Math.abs(offset) <= 1200)) return undefined;
|
|
149
183
|
if (
|
|
150
184
|
!Number.isFinite(pitch) ||
|
|
151
185
|
!Number.isFinite(start) ||
|
|
@@ -153,5 +187,33 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
|
|
|
153
187
|
duration <= 0
|
|
154
188
|
)
|
|
155
189
|
return undefined;
|
|
156
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
type: "add-note",
|
|
192
|
+
pitch,
|
|
193
|
+
start,
|
|
194
|
+
duration,
|
|
195
|
+
velocity: 0.8,
|
|
196
|
+
...(offset !== 0 ? { cents: offset } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The track patch an instrument word means: a 0.6 resolver word for the
|
|
202
|
+
* string engine also writes its preset (`instrument nylon` plays the nylon
|
|
203
|
+
* string); every other word stores the instrument as before.
|
|
204
|
+
*/
|
|
205
|
+
export function instrumentPatch(word: string): {
|
|
206
|
+
instrument: string;
|
|
207
|
+
string?: { preset: string };
|
|
208
|
+
modal?: TrackModal;
|
|
209
|
+
} {
|
|
210
|
+
const meaning = resolveInstrumentWord(word);
|
|
211
|
+
if (!meaning) return { instrument: word };
|
|
212
|
+
if (meaning.field === "modal") return instrumentPatchForWord(word);
|
|
213
|
+
if (meaning.field === "string" && meaning.preset)
|
|
214
|
+
return {
|
|
215
|
+
instrument: meaning.instrument,
|
|
216
|
+
string: { preset: meaning.preset },
|
|
217
|
+
};
|
|
218
|
+
return { instrument: meaning.instrument };
|
|
157
219
|
}
|
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,
|
|
@@ -35,10 +36,15 @@ export const PREVIEWABLE_TOOLS: readonly string[] = Object.freeze([
|
|
|
35
36
|
"set_mix",
|
|
36
37
|
"set_automation",
|
|
37
38
|
"set_fx",
|
|
39
|
+
"set_rig",
|
|
38
40
|
"set_synth",
|
|
41
|
+
"set_string",
|
|
42
|
+
"set_modal",
|
|
39
43
|
"set_sample",
|
|
44
|
+
"fit_sample",
|
|
40
45
|
"set_effects",
|
|
41
46
|
"set_wavetable",
|
|
47
|
+
"set_granular",
|
|
42
48
|
"set_drum_kit",
|
|
43
49
|
"use_sound",
|
|
44
50
|
]);
|
|
@@ -51,6 +57,14 @@ export type PreviewHost = Readonly<{
|
|
|
51
57
|
render?: (score: TrackScore) => Promise<RenderedAudio> | RenderedAudio;
|
|
52
58
|
/** Play the snippet once; returns false when nothing could sound. */
|
|
53
59
|
play?: (audio: RenderedAudio) => boolean | Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* Render and measure for measure_mix (the host adds decoded samples), at
|
|
62
|
+
* `sampleRate` when given and the score's export rate otherwise.
|
|
63
|
+
*/
|
|
64
|
+
measure?: (
|
|
65
|
+
score: TrackScore,
|
|
66
|
+
options?: Readonly<{ sampleRate?: number }>,
|
|
67
|
+
) => Promise<ScoreMeasurement>;
|
|
54
68
|
}>;
|
|
55
69
|
|
|
56
70
|
/** Thrown for a candidate the tool refuses; nothing is rendered. */
|
|
@@ -145,7 +159,7 @@ export function previewSoundTool(
|
|
|
145
159
|
return {
|
|
146
160
|
name: "preview_sound",
|
|
147
161
|
description:
|
|
148
|
-
"Hear a track, or a candidate sound change, without committing it. changes: sound tool calls to try ({tool, args} for set_fx, set_synth, set_wavetable, set_instrument, set_sample, set_effects, set_mix, set_automation, set_drum_kit, use_sound), applied to a copy. Renders the track's notes over up to 4 bars (or a short phrase by role when it has none), solo or in context, and returns RMS/peak dBFS, spectral centroid and a one-line description for the current and the candidate sound. When the user's window is open it plays the snippet once. Then commit with the normal tools if it sounds right.",
|
|
162
|
+
"Hear a track, or a candidate sound change, without committing it. changes: sound tool calls to try ({tool, args} for set_fx, set_rig, set_synth, set_string, set_modal, set_wavetable, set_instrument, set_sample, fit_sample, set_effects, set_mix, set_automation, set_drum_kit, use_sound), applied to a copy. Renders the track's notes over up to 4 bars (or a short phrase by role when it has none), solo or in context, and returns RMS/peak dBFS, spectral centroid and a one-line description for the current and the candidate sound. When the user's window is open it plays the snippet once. Then commit with the normal tools if it sounds right.",
|
|
149
163
|
parameters: {
|
|
150
164
|
type: "object",
|
|
151
165
|
properties: {
|
|
@@ -217,22 +231,31 @@ export function previewSoundTool(
|
|
|
217
231
|
: after.region;
|
|
218
232
|
const render =
|
|
219
233
|
action.preview?.render ?? ((value) => renderScorePcm(value));
|
|
220
|
-
const snippet = async (value: TrackScore) => {
|
|
234
|
+
const snippet = async (value: TrackScore, masterGainDb?: number) => {
|
|
221
235
|
const preview = previewScore(value, trackId, {
|
|
222
236
|
context: inContext,
|
|
223
237
|
region,
|
|
238
|
+
...(masterGainDb === undefined ? {} : { masterGainDb }),
|
|
224
239
|
});
|
|
225
240
|
if (!preview) return undefined;
|
|
226
241
|
const audio = await render(preview.score);
|
|
227
242
|
return { audio, stats: analyzePcm(audio.pcm, audio.sampleRate) };
|
|
228
243
|
};
|
|
229
|
-
const next = await snippet(candidate.score);
|
|
230
|
-
if (!next) throw new PreviewToolError(`unknown track ${trackId}`);
|
|
231
244
|
const before =
|
|
232
245
|
changes.length > 0 &&
|
|
233
246
|
context.score.tracks.some((track) => track.id === trackId)
|
|
234
247
|
? await snippet(context.score)
|
|
235
248
|
: undefined;
|
|
249
|
+
// The after snippet plays the master at the drive the before one
|
|
250
|
+
// used, so a level change is not normalized away by the target
|
|
251
|
+
// (unless the change is to the master itself).
|
|
252
|
+
const fixedGain = changes.some(
|
|
253
|
+
(change) => change.tool === "set_master",
|
|
254
|
+
)
|
|
255
|
+
? undefined
|
|
256
|
+
: before?.audio.master?.gainDb;
|
|
257
|
+
const next = await snippet(candidate.score, fixedGain);
|
|
258
|
+
if (!next) throw new PreviewToolError(`unknown track ${trackId}`);
|
|
236
259
|
let played = false;
|
|
237
260
|
if (play && action.preview?.play)
|
|
238
261
|
played = await action.preview.play(next.audio);
|