@hraness/dawg 0.5.0 → 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 +29 -0
- package/DAWG.md +269 -13
- package/README.md +4 -4
- package/core/diff.ts +4 -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/resonators.ts +574 -0
- package/core/score.ts +181 -6
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +169 -13
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +1114 -21
- package/core/sections.ts +34 -8
- package/core/strings.ts +845 -0
- package/guides/effects.md +3 -1
- package/guides/sounds.md +4 -0
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +7 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +39 -2
- package/src/agent/preview-tool.ts +6 -1
- package/src/agent/tools.ts +538 -14
- package/src/agent/xcb-agent.ts +7 -0
- package/src/audio/arrange.ts +21 -3
- 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/chain.ts +6 -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/engine.ts +10 -1
- 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 +231 -12
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +2 -0
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +163 -17
- 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/warp.ts +61 -0
- package/src/audio/wav.ts +78 -6
- package/src/commands/expression.ts +9 -2
- 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 +74 -3
- package/src/commands/keys.ts +353 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/time.ts +4 -1
- package/src/main.ts +210 -8
- package/src/project/check.ts +13 -0
- package/src/render.ts +33 -4
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +332 -9
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/play-session.ts +45 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modelled piano commands (0.6 keys lane, `core/keys.ts`):
|
|
3
|
+
*
|
|
4
|
+
* piano the focused track becomes the modelled grand
|
|
5
|
+
* piano <preset> grand ballad upright felt lofi honkytonk prepared
|
|
6
|
+
* grand | upright | felt … the same, by the preset word alone
|
|
7
|
+
* keys list this track's piano settings
|
|
8
|
+
* keys preset <name> load a preset (instrument, keys and its effects)
|
|
9
|
+
* keys <param> <value> […] set parameters (hardness 0.4 decay 1.5 …)
|
|
10
|
+
* keys <param> off unset one parameter (back to the preset's)
|
|
11
|
+
* keys reset the family's own sound (`keys: {}`),
|
|
12
|
+
* dropping the preset's unedited effects
|
|
13
|
+
*
|
|
14
|
+
* Every new write of `piano` stores `instrument: "grand"` with `keys`; a
|
|
15
|
+
* stored legacy `instrument: "piano"` is never rewritten unless asked. Each
|
|
16
|
+
* command is one `updateTrack` revision and one undo step.
|
|
17
|
+
*/
|
|
18
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
19
|
+
import {
|
|
20
|
+
KEYS_PARAMS,
|
|
21
|
+
KEYS_PRESETS,
|
|
22
|
+
KEYS_SIMPLE,
|
|
23
|
+
isKeysPreset,
|
|
24
|
+
isPianoFamily,
|
|
25
|
+
keysParamName,
|
|
26
|
+
normalizeKeys,
|
|
27
|
+
pianoWrite,
|
|
28
|
+
type TrackKeys,
|
|
29
|
+
} from "../../core/keys.ts";
|
|
30
|
+
import {
|
|
31
|
+
createScore,
|
|
32
|
+
ScoreValidationError,
|
|
33
|
+
updateTrack,
|
|
34
|
+
type TrackPatch,
|
|
35
|
+
type TrackScore,
|
|
36
|
+
} from "../../core/score.ts";
|
|
37
|
+
import { parseParamValue } from "./fx.ts";
|
|
38
|
+
|
|
39
|
+
export type KeysCommand =
|
|
40
|
+
| { type: "keys-list" }
|
|
41
|
+
| { type: "keys-presets" }
|
|
42
|
+
| { type: "keys-reset" }
|
|
43
|
+
| { type: "keys-preset"; preset: string }
|
|
44
|
+
| {
|
|
45
|
+
type: "keys-set";
|
|
46
|
+
/** `null` unsets a parameter. */
|
|
47
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The words that start a keys command (registered with /help). */
|
|
51
|
+
export const KEYS_COMMAND_WORDS = Object.freeze([
|
|
52
|
+
"keys",
|
|
53
|
+
"piano",
|
|
54
|
+
...Object.keys(KEYS_PRESETS).filter((name) => name !== "lofi"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
export function parseKeysCommand(prompt: string): KeysCommand | undefined {
|
|
58
|
+
if (prompt.length > 1_024) return undefined;
|
|
59
|
+
let words = prompt.trim().toLowerCase().split(/\s+/);
|
|
60
|
+
// `instrument piano` / `sound grand`: the generic instrument command with
|
|
61
|
+
// a keys word is a new write, so it stores the modelled piano.
|
|
62
|
+
if (
|
|
63
|
+
words.length === 2 &&
|
|
64
|
+
["instrument", "sound", "voice"].includes(words[0]!) &&
|
|
65
|
+
pianoWrite(words[1]!)
|
|
66
|
+
)
|
|
67
|
+
words = [words[1]!];
|
|
68
|
+
const head = words[0] ?? "";
|
|
69
|
+
// `piano`, `piano <preset>`, and a bare preset word (`grand`, `felt`).
|
|
70
|
+
if (head === "piano" || head === "uprightpiano" || head === "feltpiano") {
|
|
71
|
+
if (words.length === 1)
|
|
72
|
+
return { type: "keys-preset", preset: pianoWrite(head)!.preset! };
|
|
73
|
+
return words.length === 2 && isKeysPreset(words[1]!)
|
|
74
|
+
? { type: "keys-preset", preset: words[1]! }
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
if (head !== "keys") {
|
|
78
|
+
if (words.length === 1 && isKeysPreset(head) && head !== "lofi")
|
|
79
|
+
return { type: "keys-preset", preset: head };
|
|
80
|
+
if (words.length === 2 && isKeysPreset(head) && words[1] === "piano")
|
|
81
|
+
return { type: "keys-preset", preset: head };
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (words.length === 1) return { type: "keys-list" };
|
|
85
|
+
const rest = words.slice(1);
|
|
86
|
+
if (rest.length === 1 && (rest[0] === "reset" || rest[0] === "off"))
|
|
87
|
+
return { type: "keys-reset" };
|
|
88
|
+
if (rest.length === 1 && rest[0] === "presets")
|
|
89
|
+
return { type: "keys-presets" };
|
|
90
|
+
if (rest[0] === "preset")
|
|
91
|
+
return rest.length === 2 && isKeysPreset(rest[1]!)
|
|
92
|
+
? { type: "keys-preset", preset: rest[1]! }
|
|
93
|
+
: rest.length === 1
|
|
94
|
+
? { type: "keys-presets" }
|
|
95
|
+
: undefined;
|
|
96
|
+
if (rest.length === 1 && isKeysPreset(rest[0]!))
|
|
97
|
+
return { type: "keys-preset", preset: rest[0]! };
|
|
98
|
+
if (rest.length % 2 !== 0) return undefined;
|
|
99
|
+
const values: Record<string, number | string | null> = {};
|
|
100
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
101
|
+
const name = keysParamName(rest[index]!);
|
|
102
|
+
if (!name) return undefined;
|
|
103
|
+
const word = rest[index + 1]!;
|
|
104
|
+
if (word === "off" || word === "unset") {
|
|
105
|
+
values[name] = null;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const value = parseParamValue(KEYS_PARAMS[name]!, word);
|
|
109
|
+
if (value === undefined || typeof value === "boolean") return undefined;
|
|
110
|
+
values[name] = value as number | string;
|
|
111
|
+
}
|
|
112
|
+
return { type: "keys-set", values };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function describeKeys(keys: TrackKeys | undefined): string {
|
|
116
|
+
if (!keys) return "off";
|
|
117
|
+
const entries = Object.entries(keys).filter(([key]) => key !== "preset");
|
|
118
|
+
const parts = [
|
|
119
|
+
...(keys.preset ? [`preset ${keys.preset}`] : []),
|
|
120
|
+
...entries.map(([key, value]) => `${key} ${String(value)}`),
|
|
121
|
+
];
|
|
122
|
+
return parts.length > 0 ? parts.join(" · ") : "defaults";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type KeysResult = Readonly<{
|
|
126
|
+
ok: boolean;
|
|
127
|
+
message: string;
|
|
128
|
+
next?: TrackScore;
|
|
129
|
+
kind?: string;
|
|
130
|
+
payload?: Record<string, unknown>;
|
|
131
|
+
}>;
|
|
132
|
+
|
|
133
|
+
/** One line per preset for `keys presets`, /help and the agent prompt. */
|
|
134
|
+
export function keysPresetLines(): string[] {
|
|
135
|
+
return Object.entries(KEYS_PRESETS).map(
|
|
136
|
+
([name, preset]) => `${name} · ${preset.doc} · ${preset.styles}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type PresetTrack = Pick<
|
|
141
|
+
TrackScore["tracks"][number],
|
|
142
|
+
"fx" | "keys" | "filter" | "reverb"
|
|
143
|
+
>;
|
|
144
|
+
|
|
145
|
+
/** A preset's effects as the score stores them (defaults filled in). */
|
|
146
|
+
const storedPresetFx = new Map<string, Track>();
|
|
147
|
+
type Track = TrackScore["tracks"][number];
|
|
148
|
+
function presetEffects(preset: string): Track | undefined {
|
|
149
|
+
if (storedPresetFx.has(preset)) return storedPresetFx.get(preset);
|
|
150
|
+
const write = pianoWrite(preset);
|
|
151
|
+
const track = write
|
|
152
|
+
? createScore({
|
|
153
|
+
tracks: [
|
|
154
|
+
{
|
|
155
|
+
id: "p",
|
|
156
|
+
name: "p",
|
|
157
|
+
instrument: write.instrument,
|
|
158
|
+
...(write.filter ? { filter: write.filter } : {}),
|
|
159
|
+
...(write.fx ? { fx: write.fx } : {}),
|
|
160
|
+
...(write.reverb ? { reverb: write.reverb } : {}),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}).tracks[0]
|
|
164
|
+
: undefined;
|
|
165
|
+
if (track) storedPresetFx.set(preset, track);
|
|
166
|
+
return track;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when `stored` still equals the preset's stored value. */
|
|
170
|
+
function unchanged(stored: unknown, preset: unknown): boolean {
|
|
171
|
+
return (
|
|
172
|
+
stored !== undefined &&
|
|
173
|
+
preset !== undefined &&
|
|
174
|
+
JSON.stringify(stored) === JSON.stringify(preset)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Clears the effects the track's current preset brought (its filter, fx
|
|
180
|
+
* entries and reverb) while they still hold the preset's values, so moving
|
|
181
|
+
* between presets or resetting never piles them up. Effects the user has
|
|
182
|
+
* since edited stay.
|
|
183
|
+
*/
|
|
184
|
+
export function keysPresetClear(track: PresetTrack): TrackPatch {
|
|
185
|
+
const name = track.keys?.preset;
|
|
186
|
+
const old = typeof name === "string" ? presetEffects(name) : undefined;
|
|
187
|
+
if (!old) return {};
|
|
188
|
+
const patch: {
|
|
189
|
+
filter?: null;
|
|
190
|
+
reverb?: null;
|
|
191
|
+
fx?: TrackPatch["fx"];
|
|
192
|
+
} = {};
|
|
193
|
+
if (old.filter && unchanged(track.filter, old.filter)) patch.filter = null;
|
|
194
|
+
if (old.reverb && unchanged(track.reverb, old.reverb)) patch.reverb = null;
|
|
195
|
+
if (old.fx && track.fx) {
|
|
196
|
+
const fx: Record<string, unknown> = { ...track.fx };
|
|
197
|
+
let removed = false;
|
|
198
|
+
for (const [effect, params] of Object.entries(old.fx))
|
|
199
|
+
if (unchanged(fx[effect], params)) {
|
|
200
|
+
delete fx[effect];
|
|
201
|
+
removed = true;
|
|
202
|
+
}
|
|
203
|
+
if (removed)
|
|
204
|
+
patch.fx = Object.keys(fx).length > 0 ? (fx as TrackPatch["fx"]) : null;
|
|
205
|
+
}
|
|
206
|
+
return patch;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The track patch for a preset write (instrument, keys, preset effects),
|
|
211
|
+
* first clearing the effects the previous preset brought.
|
|
212
|
+
*/
|
|
213
|
+
export function keysPresetPatch(
|
|
214
|
+
track: PresetTrack,
|
|
215
|
+
preset: string,
|
|
216
|
+
): TrackPatch {
|
|
217
|
+
const write = pianoWrite(preset)!;
|
|
218
|
+
const clear = keysPresetClear(track);
|
|
219
|
+
const fx = "fx" in clear ? (clear.fx ?? undefined) : track.fx;
|
|
220
|
+
return {
|
|
221
|
+
...clear,
|
|
222
|
+
instrument: write.instrument,
|
|
223
|
+
keys: write.keys,
|
|
224
|
+
...(write.filter ? { filter: write.filter } : {}),
|
|
225
|
+
...(write.fx
|
|
226
|
+
? { fx: { ...(fx ?? {}), ...write.fx } as TrackPatch["fx"] }
|
|
227
|
+
: {}),
|
|
228
|
+
...(write.reverb ? { reverb: write.reverb } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function applyKeysCommand(
|
|
233
|
+
score: TrackScore,
|
|
234
|
+
trackId: string,
|
|
235
|
+
command: KeysCommand,
|
|
236
|
+
): KeysResult {
|
|
237
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
238
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
239
|
+
if (command.type === "keys-presets")
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
message: `keys presets · ${Object.keys(KEYS_PRESETS).join(" ")}`,
|
|
243
|
+
};
|
|
244
|
+
const piano = isPianoFamily(track.instrument);
|
|
245
|
+
if (command.type === "keys-list")
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
message: piano
|
|
249
|
+
? `keys · ${track.instrument} · ${describeKeys(track.keys)} · basics ${KEYS_SIMPLE.join(" ")}`
|
|
250
|
+
: `keys · ${trackId} is ${track.instrument}; type piano (or grand upright felt honkytonk prepared) for the modelled piano`,
|
|
251
|
+
};
|
|
252
|
+
if (track.sampler && command.type !== "keys-preset")
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
message: `keys · ${trackId} is a sampler track; type piano to make it a modelled piano`,
|
|
256
|
+
};
|
|
257
|
+
let patch: TrackPatch;
|
|
258
|
+
if (command.type === "keys-preset") {
|
|
259
|
+
patch = {
|
|
260
|
+
...keysPresetPatch(track, command.preset),
|
|
261
|
+
...(track.sampler ? { sampler: null } : {}),
|
|
262
|
+
...(track.synth ? { synth: null } : {}),
|
|
263
|
+
};
|
|
264
|
+
} else if (!piano) {
|
|
265
|
+
// `keys` is also the legacy synth sound (`instrument keys`): name both
|
|
266
|
+
// ways out.
|
|
267
|
+
const keysSynth = track.instrument === "keys";
|
|
268
|
+
const [first] = Object.keys(
|
|
269
|
+
command.type === "keys-set" ? command.values : {},
|
|
270
|
+
);
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
message: keysSynth
|
|
274
|
+
? `keys · keys shapes the modelled piano; ${trackId} is the keys synth: use synth ${first ?? "decay"} …, or type piano`
|
|
275
|
+
: `keys · ${trackId} is ${track.instrument}; type piano first (keys shapes the modelled piano)`,
|
|
276
|
+
};
|
|
277
|
+
} else if (command.type === "keys-reset") {
|
|
278
|
+
// The family's own sound: the preset and the effects it brought go.
|
|
279
|
+
patch = { ...keysPresetClear(track), keys: {} };
|
|
280
|
+
} else {
|
|
281
|
+
const keys: Record<string, unknown> = { ...(track.keys ?? {}) };
|
|
282
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
283
|
+
if (value === null) delete keys[key];
|
|
284
|
+
else keys[key] = value;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
patch = { keys: normalizeKeys(keys) ?? {} };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error instanceof FxValidationError)
|
|
290
|
+
return { ok: false, message: `keys · ${error.message}` };
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
let next: TrackScore;
|
|
295
|
+
try {
|
|
296
|
+
next = updateTrack(score, trackId, patch);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (
|
|
299
|
+
error instanceof ScoreValidationError ||
|
|
300
|
+
error instanceof FxValidationError
|
|
301
|
+
)
|
|
302
|
+
return { ok: false, message: `keys · ${error.message}` };
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
const stored = next.tracks.find((candidate) => candidate.id === trackId)!;
|
|
306
|
+
const extras =
|
|
307
|
+
command.type === "keys-preset"
|
|
308
|
+
? [
|
|
309
|
+
...(patch.reverb ? ["reverb"] : []),
|
|
310
|
+
...(patch.filter ? ["filter"] : []),
|
|
311
|
+
...Object.keys(pianoWrite(command.preset)?.fx ?? {}),
|
|
312
|
+
]
|
|
313
|
+
: [];
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
message:
|
|
317
|
+
command.type === "keys-preset"
|
|
318
|
+
? `keys · ${stored.instrument} · preset ${command.preset} · ${KEYS_PRESETS[command.preset]!.doc}${extras.length > 0 ? ` · with ${extras.join(" ")}` : ""}`
|
|
319
|
+
: `keys · ${stored.instrument} · ${describeKeys(stored.keys)}`,
|
|
320
|
+
next,
|
|
321
|
+
kind: "score.keys",
|
|
322
|
+
payload: {
|
|
323
|
+
trackId,
|
|
324
|
+
instrument: stored.instrument,
|
|
325
|
+
keys: stored.keys ?? null,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The fields a new track named after a piano word starts with
|
|
332
|
+
* (`/track piano` → the modelled grand); empty for any other name. `lofi`
|
|
333
|
+
* is left out: it also names a drum kit and pattern.
|
|
334
|
+
*/
|
|
335
|
+
export function newPianoTrack(name: string): {
|
|
336
|
+
instrument?: string;
|
|
337
|
+
keys?: TrackKeys;
|
|
338
|
+
filter?: TrackPatch["filter"];
|
|
339
|
+
fx?: TrackPatch["fx"];
|
|
340
|
+
reverb?: TrackPatch["reverb"];
|
|
341
|
+
} {
|
|
342
|
+
const word = name.toLowerCase();
|
|
343
|
+
if (word === "lofi") return {};
|
|
344
|
+
const write = pianoWrite(word);
|
|
345
|
+
if (!write) return {};
|
|
346
|
+
return {
|
|
347
|
+
instrument: write.instrument,
|
|
348
|
+
keys: write.keys,
|
|
349
|
+
...(write.filter ? { filter: write.filter } : {}),
|
|
350
|
+
...(write.fx ? { fx: write.fx } : {}),
|
|
351
|
+
...(write.reverb ? { reverb: write.reverb } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `modal` prompt command (0.6): modal percussion on the focused track
|
|
3
|
+
* (core/resonators.ts). The menu and the `set_modal` agent tool build the
|
|
4
|
+
* same edits.
|
|
5
|
+
*
|
|
6
|
+
* modal show the track's preset and overrides
|
|
7
|
+
* modal <preset> play a preset: marimba vibes xylophone
|
|
8
|
+
* glock celesta chimes kalimba mbira
|
|
9
|
+
* steelpan bowl gong timpani (and the
|
|
10
|
+
* aliases vibraphone glockenspiel
|
|
11
|
+
* tubular thumbpiano gongageng)
|
|
12
|
+
* modal preset <name> the same, spelled out
|
|
13
|
+
* modal mallet <yarn|cord|rubber|plastic|brass>
|
|
14
|
+
* modal <param> <value> [<param> <value>…] hardness 0.8, ring 3 …
|
|
15
|
+
* modal <param> off back to the preset's value
|
|
16
|
+
* modal reset clear overrides (keep the preset)
|
|
17
|
+
* modal off back to the legacy marimba voice
|
|
18
|
+
* modal list | presets modal the presets with one-line characters
|
|
19
|
+
*
|
|
20
|
+
* Any edit turns the track into a modal track (`instrument "modal"`); the
|
|
21
|
+
* legacy `marimba` tone is untouched until then. One `updateTrack` revision
|
|
22
|
+
* and one undo step per command.
|
|
23
|
+
*/
|
|
24
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_MODAL_PRESET,
|
|
27
|
+
MODAL_INSTRUMENT,
|
|
28
|
+
MODAL_PARAMS,
|
|
29
|
+
MODAL_PRESETS,
|
|
30
|
+
MODAL_PRESET_NAMES,
|
|
31
|
+
MODAL_SIMPLE,
|
|
32
|
+
modalParamName,
|
|
33
|
+
modalPresetFor,
|
|
34
|
+
normalizeModal,
|
|
35
|
+
type TrackModal,
|
|
36
|
+
} from "../../core/resonators.ts";
|
|
37
|
+
import {
|
|
38
|
+
ScoreValidationError,
|
|
39
|
+
updateTrack,
|
|
40
|
+
type TrackScore,
|
|
41
|
+
} from "../../core/score.ts";
|
|
42
|
+
import { parseParamValue } from "./fx.ts";
|
|
43
|
+
|
|
44
|
+
export type ModalCommand =
|
|
45
|
+
| { type: "modal-show" }
|
|
46
|
+
| { type: "modal-list" }
|
|
47
|
+
| { type: "modal-reset" }
|
|
48
|
+
| { type: "modal-off" }
|
|
49
|
+
| { type: "modal-preset"; preset: string }
|
|
50
|
+
| { type: "modal-usage"; message: string }
|
|
51
|
+
| {
|
|
52
|
+
type: "modal-set";
|
|
53
|
+
/** `null` returns a parameter to the preset's value. */
|
|
54
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The voice `modal off` returns a track to: the legacy marimba tone. */
|
|
58
|
+
export const MODAL_OFF_INSTRUMENT = "marimba";
|
|
59
|
+
|
|
60
|
+
export const MODAL_USAGE =
|
|
61
|
+
"modal <preset> | modal <body> | modal <param> <value> | modal mallet <name> | modal reset | modal off | modal presets";
|
|
62
|
+
|
|
63
|
+
function rangeOf(name: string): string {
|
|
64
|
+
const spec = MODAL_PARAMS[name]!;
|
|
65
|
+
if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
|
|
66
|
+
if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseModalCommand(prompt: string): ModalCommand | undefined {
|
|
71
|
+
const words = prompt.trim().toLowerCase().split(/\s+/);
|
|
72
|
+
if (words[0] === "presets" && words[1] === "modal" && words.length === 2)
|
|
73
|
+
return { type: "modal-list" };
|
|
74
|
+
if (words[0] !== "modal") return undefined;
|
|
75
|
+
if (words.length === 1) return { type: "modal-show" };
|
|
76
|
+
if (prompt.length > 512) return undefined;
|
|
77
|
+
const rest = words.slice(1);
|
|
78
|
+
if (rest.length === 1 && rest[0] === "reset") return { type: "modal-reset" };
|
|
79
|
+
if (rest.length === 1 && rest[0] === "off") return { type: "modal-off" };
|
|
80
|
+
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
81
|
+
return { type: "modal-list" };
|
|
82
|
+
if (rest[0] === "preset") {
|
|
83
|
+
const preset = rest.length === 2 ? modalPresetFor(rest[1]!) : undefined;
|
|
84
|
+
return preset
|
|
85
|
+
? { type: "modal-preset", preset }
|
|
86
|
+
: {
|
|
87
|
+
type: "modal-usage",
|
|
88
|
+
message: `modal preset is one of ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (rest.length === 1) {
|
|
92
|
+
const preset = modalPresetFor(rest[0]!);
|
|
93
|
+
if (preset) return { type: "modal-preset", preset };
|
|
94
|
+
// A body word (saron, kempul, bonang, gender ...) sets the body: the
|
|
95
|
+
// gamelan presets come later, the bodies already ship.
|
|
96
|
+
const body = MODAL_PARAMS.body;
|
|
97
|
+
if (body?.kind === "enum" && body.values.includes(rest[0]!))
|
|
98
|
+
return { type: "modal-set", values: { body: rest[0]! } };
|
|
99
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
100
|
+
}
|
|
101
|
+
if (rest.length % 2 !== 0)
|
|
102
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
103
|
+
const values: Record<string, number | string | null> = {};
|
|
104
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
105
|
+
const name = modalParamName(rest[index]!);
|
|
106
|
+
if (!name)
|
|
107
|
+
return {
|
|
108
|
+
type: "modal-usage",
|
|
109
|
+
message: `modal has no parameter ${rest[index]!.slice(0, 24)} · ${MODAL_SIMPLE.join(" ")} …`,
|
|
110
|
+
};
|
|
111
|
+
const word = rest[index + 1]!;
|
|
112
|
+
if (word === "off" || word === "unset") {
|
|
113
|
+
values[name] = null;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const value = parseParamValue(MODAL_PARAMS[name]!, word);
|
|
117
|
+
if (value === undefined || typeof value === "boolean")
|
|
118
|
+
return { type: "modal-usage", message: rangeOf(name) };
|
|
119
|
+
values[name] = value;
|
|
120
|
+
}
|
|
121
|
+
return { type: "modal-set", values };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `preset vibes · hardness 0.6 · ring 3`, overrides in canonical order. */
|
|
125
|
+
export function describeModal(modal: TrackModal | undefined): string {
|
|
126
|
+
if (!modal) return "not a modal track";
|
|
127
|
+
const preset = modal.preset ?? DEFAULT_MODAL_PRESET;
|
|
128
|
+
const overrides = Object.entries(modal)
|
|
129
|
+
.filter(([key]) => key !== "preset")
|
|
130
|
+
.map(([key, value]) => `${key} ${value}`);
|
|
131
|
+
return [`preset ${preset}`, ...overrides].join(" · ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One line per preset, for `modal list` and the agent brief. */
|
|
135
|
+
export function modalListLines(): string[] {
|
|
136
|
+
return MODAL_PRESET_NAMES.map(
|
|
137
|
+
(name) => `${name.padEnd(10)} ${MODAL_PRESETS[name].doc}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type ModalResult = Readonly<{
|
|
142
|
+
ok: boolean;
|
|
143
|
+
message: string;
|
|
144
|
+
next?: TrackScore;
|
|
145
|
+
kind?: string;
|
|
146
|
+
payload?: Record<string, unknown>;
|
|
147
|
+
}>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The modal field a command leaves on a track (pure; the agent tool shares
|
|
151
|
+
* it). A preset switch keeps the overrides; `reset` keeps the preset.
|
|
152
|
+
* `mallet` and `hardness` are one choice, so the one written last wins.
|
|
153
|
+
*/
|
|
154
|
+
export function nextModal(
|
|
155
|
+
current: TrackModal | undefined,
|
|
156
|
+
command: Extract<
|
|
157
|
+
ModalCommand,
|
|
158
|
+
{ type: "modal-reset" | "modal-preset" | "modal-set" }
|
|
159
|
+
>,
|
|
160
|
+
): TrackModal {
|
|
161
|
+
const base: Record<string, unknown> = { ...current };
|
|
162
|
+
if (command.type === "modal-reset")
|
|
163
|
+
return normalizeModal(current?.preset ? { preset: current.preset } : {})!;
|
|
164
|
+
if (command.type === "modal-preset") base.preset = command.preset;
|
|
165
|
+
else
|
|
166
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
167
|
+
if (value === null) delete base[key];
|
|
168
|
+
else {
|
|
169
|
+
base[key] = value;
|
|
170
|
+
if (key === "mallet" && !("hardness" in command.values))
|
|
171
|
+
delete base.hardness;
|
|
172
|
+
if (key === "hardness" && !("mallet" in command.values))
|
|
173
|
+
delete base.mallet;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return normalizeModal(base)!;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function applyModalCommand(
|
|
180
|
+
score: TrackScore,
|
|
181
|
+
trackId: string,
|
|
182
|
+
command: ModalCommand,
|
|
183
|
+
): ModalResult {
|
|
184
|
+
if (command.type === "modal-usage")
|
|
185
|
+
return { ok: false, message: `modal · ${command.message}` };
|
|
186
|
+
if (command.type === "modal-list")
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
message: `modal presets · ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
190
|
+
};
|
|
191
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
192
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
193
|
+
if (command.type === "modal-show")
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
message:
|
|
197
|
+
track.instrument === MODAL_INSTRUMENT && track.modal
|
|
198
|
+
? `modal · ${describeModal(track.modal)}`
|
|
199
|
+
: `modal · ${trackId} plays ${track.instrument} · modal <preset> to switch (${MODAL_PRESET_NAMES.join(" ")})`,
|
|
200
|
+
};
|
|
201
|
+
if (track.sampler || track.instrument === "kit")
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `modal · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
|
|
205
|
+
};
|
|
206
|
+
const current =
|
|
207
|
+
track.instrument === MODAL_INSTRUMENT ? track.modal : undefined;
|
|
208
|
+
if (command.type === "modal-off") {
|
|
209
|
+
if (track.instrument !== MODAL_INSTRUMENT)
|
|
210
|
+
return { ok: true, message: "modal · already off" };
|
|
211
|
+
// Like `string off`: leave the engine for the legacy voice.
|
|
212
|
+
const next = updateTrack(score, trackId, {
|
|
213
|
+
instrument: MODAL_OFF_INSTRUMENT,
|
|
214
|
+
modal: null,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
message: `modal · off (${MODAL_OFF_INSTRUMENT}) · modal ${current?.preset ?? DEFAULT_MODAL_PRESET} turns it back on`,
|
|
219
|
+
next,
|
|
220
|
+
kind: "score.modal",
|
|
221
|
+
payload: { trackId, instrument: MODAL_OFF_INSTRUMENT, modal: null },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
let modal: TrackModal;
|
|
225
|
+
let next: TrackScore;
|
|
226
|
+
try {
|
|
227
|
+
modal = nextModal(current, command);
|
|
228
|
+
next = updateTrack(score, trackId, {
|
|
229
|
+
instrument: MODAL_INSTRUMENT,
|
|
230
|
+
modal,
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (
|
|
234
|
+
error instanceof ScoreValidationError ||
|
|
235
|
+
error instanceof FxValidationError
|
|
236
|
+
)
|
|
237
|
+
return { ok: false, message: `modal · ${error.message}` };
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
message: `modal · ${describeModal(modal)}`,
|
|
243
|
+
next,
|
|
244
|
+
kind: "score.modal",
|
|
245
|
+
payload: { trackId, instrument: MODAL_INSTRUMENT, modal },
|
|
246
|
+
};
|
|
247
|
+
}
|