@hraness/dawg 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/DAWG.md +605 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +27 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +280 -278
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1119 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +1286 -244
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `fx` prompt command: turn effects on and off and set any parameter.
|
|
3
|
+
*
|
|
4
|
+
* fx list the focused track's effects
|
|
5
|
+
* fx <effect> on|off|reset enable with defaults, remove, reset
|
|
6
|
+
* fx <effect> preset <name> load a named preset (FX_PRESETS)
|
|
7
|
+
* fx <effect> <param> <value> set one parameter (enables it)
|
|
8
|
+
* fx <effect> <number> set its first parameter (`fx orbit 2`)
|
|
9
|
+
* fx <effect> <param> <value> <param> <value>… several at once
|
|
10
|
+
* fx reverb ir <impulse>|off convolution reverb (Strudel `ir`):
|
|
11
|
+
* room|hall|plate, a pack sound or a
|
|
12
|
+
* project file; `fx ir hall` too
|
|
13
|
+
*
|
|
14
|
+
* Effects are every `FX_CHAIN` stage except pan (see core/fx.ts), plus
|
|
15
|
+
* aliases (`dist`, `comp`, `room`, `bitcrush`, `trem`, `auto-filter`…).
|
|
16
|
+
* Parameter names are the spec names or any Strudel name the spec lists
|
|
17
|
+
* (`fx delay delayfeedback 0.4`, `fx filter lpq 0.3`). Booleans take
|
|
18
|
+
* on/off/true/false; enums take one of their values. Each command is one
|
|
19
|
+
* `updateTrack` revision and one undo step.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
EFFECT_NAMES,
|
|
23
|
+
FX_PRESETS,
|
|
24
|
+
FX_CHAIN,
|
|
25
|
+
TRACK_EFFECT_SPECS,
|
|
26
|
+
effectSpec,
|
|
27
|
+
isEffectName,
|
|
28
|
+
normalizeParam,
|
|
29
|
+
FxValidationError,
|
|
30
|
+
type EffectName,
|
|
31
|
+
type FxName,
|
|
32
|
+
type FxValues,
|
|
33
|
+
type ParamSpec,
|
|
34
|
+
type TrackEffectName,
|
|
35
|
+
} from "../../core/fx.ts";
|
|
36
|
+
import {
|
|
37
|
+
ScoreValidationError,
|
|
38
|
+
normalizeReverbIr,
|
|
39
|
+
updateTrack,
|
|
40
|
+
type SampleRef,
|
|
41
|
+
type Track,
|
|
42
|
+
type TrackPatch,
|
|
43
|
+
type TrackScore,
|
|
44
|
+
} from "../../core/score.ts";
|
|
45
|
+
|
|
46
|
+
export type FxCommand =
|
|
47
|
+
| { type: "fx-list" }
|
|
48
|
+
| { type: "fx-on"; effect: EffectName }
|
|
49
|
+
| { type: "fx-off"; effect: EffectName }
|
|
50
|
+
| { type: "fx-reset"; effect: EffectName }
|
|
51
|
+
| { type: "fx-preset"; effect: EffectName; preset: string }
|
|
52
|
+
/** `fx reverb ir <src>`: `null` removes the impulse (algorithmic tail). */
|
|
53
|
+
| { type: "fx-ir"; ir: string | null }
|
|
54
|
+
| {
|
|
55
|
+
type: "fx-set";
|
|
56
|
+
effect: EffectName;
|
|
57
|
+
values: Readonly<Record<string, number | string | boolean>>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const EFFECT_ALIASES: Readonly<Record<string, EffectName>> = Object.freeze({
|
|
61
|
+
lpf: "filter",
|
|
62
|
+
hpf: "filter",
|
|
63
|
+
bpf: "filter",
|
|
64
|
+
dj: "djf",
|
|
65
|
+
"dj-filter": "djf",
|
|
66
|
+
"auto-filter": "autofilter",
|
|
67
|
+
autof: "autofilter",
|
|
68
|
+
formant: "vowel",
|
|
69
|
+
bitcrush: "crush",
|
|
70
|
+
bitcrusher: "crush",
|
|
71
|
+
coarse: "crush",
|
|
72
|
+
distortion: "distort",
|
|
73
|
+
dist: "distort",
|
|
74
|
+
drive: "distort",
|
|
75
|
+
shape: "distort",
|
|
76
|
+
trem: "tremolo",
|
|
77
|
+
comp: "compressor",
|
|
78
|
+
compress: "compressor",
|
|
79
|
+
rotary: "leslie",
|
|
80
|
+
gain: "postgain",
|
|
81
|
+
echo: "delay",
|
|
82
|
+
bus: "orbit",
|
|
83
|
+
o: "orbit",
|
|
84
|
+
sidechain: "duck",
|
|
85
|
+
duckorbit: "duck",
|
|
86
|
+
room: "reverb",
|
|
87
|
+
verb: "reverb",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function parseEffectName(name: string): EffectName | undefined {
|
|
91
|
+
const lower = name.toLowerCase();
|
|
92
|
+
if (isEffectName(lower)) return lower;
|
|
93
|
+
return Object.prototype.hasOwnProperty.call(EFFECT_ALIASES, lower)
|
|
94
|
+
? EFFECT_ALIASES[lower]
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Spec name for a parameter typed as its name or a listed Strudel alias. */
|
|
99
|
+
export function parseParamName(
|
|
100
|
+
effect: EffectName,
|
|
101
|
+
name: string,
|
|
102
|
+
): string | undefined {
|
|
103
|
+
const params = effectSpec(effect).params;
|
|
104
|
+
const lower = name.toLowerCase();
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(params, lower)) return lower;
|
|
106
|
+
for (const [key, spec] of Object.entries(params))
|
|
107
|
+
if (spec.strudel?.some((alias) => alias.toLowerCase() === lower))
|
|
108
|
+
return key;
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a typed value for a parameter spec; `undefined` when invalid. */
|
|
113
|
+
export function parseParamValue(
|
|
114
|
+
spec: ParamSpec,
|
|
115
|
+
text: string,
|
|
116
|
+
): number | string | boolean | undefined {
|
|
117
|
+
const lower = text.toLowerCase();
|
|
118
|
+
let value: number | string | boolean;
|
|
119
|
+
if (spec.kind === "boolean") {
|
|
120
|
+
if (["on", "true", "yes", "1"].includes(lower)) value = true;
|
|
121
|
+
else if (["off", "false", "no", "0"].includes(lower)) value = false;
|
|
122
|
+
else return undefined;
|
|
123
|
+
} else if (spec.kind === "enum") value = lower;
|
|
124
|
+
else {
|
|
125
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(lower)) return undefined;
|
|
126
|
+
value = Number(lower);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return normalizeParam(spec, value, "value");
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseFxCommand(prompt: string): FxCommand | undefined {
|
|
136
|
+
const words = prompt.trim().split(/\s+/);
|
|
137
|
+
if (words[0]?.toLowerCase() !== "fx") return undefined;
|
|
138
|
+
if (words.length === 1) return { type: "fx-list" };
|
|
139
|
+
if (prompt.length > 1_024) return undefined;
|
|
140
|
+
const irWord = (word: string | undefined) =>
|
|
141
|
+
word !== undefined && ["ir", "iresponse"].includes(word.toLowerCase());
|
|
142
|
+
// `fx ir hall`, `fx reverb ir samples/church.wav`, `fx room ir off`.
|
|
143
|
+
if (irWord(words[1]) && words.length === 3) return irCommand(words[2]!);
|
|
144
|
+
const effect = parseEffectName(words[1]!);
|
|
145
|
+
if (!effect) return undefined;
|
|
146
|
+
if (effect === "reverb" && irWord(words[2]) && words.length === 4)
|
|
147
|
+
return irCommand(words[3]!);
|
|
148
|
+
const rest = words.slice(2).map((word) => word.toLowerCase());
|
|
149
|
+
if (rest.length === 1 && (rest[0] === "on" || rest[0] === "off"))
|
|
150
|
+
return { type: rest[0] === "on" ? "fx-on" : "fx-off", effect };
|
|
151
|
+
if (rest.length === 1 && rest[0] === "reset")
|
|
152
|
+
return { type: "fx-reset", effect };
|
|
153
|
+
if (rest.length === 2 && rest[0] === "preset")
|
|
154
|
+
return Object.prototype.hasOwnProperty.call(
|
|
155
|
+
FX_PRESETS[effect] ?? {},
|
|
156
|
+
rest[1]!,
|
|
157
|
+
)
|
|
158
|
+
? { type: "fx-preset", effect, preset: rest[1]! }
|
|
159
|
+
: undefined;
|
|
160
|
+
// `fx hpf cutoff 300` picks the filter type from the alias.
|
|
161
|
+
const alias = words[1]!.toLowerCase();
|
|
162
|
+
const typed: Record<string, string> =
|
|
163
|
+
effect === "filter" && (alias === "hpf" || alias === "bpf")
|
|
164
|
+
? { type: alias }
|
|
165
|
+
: {};
|
|
166
|
+
if (rest.length === 1) {
|
|
167
|
+
// `fx orbit 2`, `fx duck 3`, `fx hpf 300`: the first numeric parameter.
|
|
168
|
+
const params = effectSpec(effect).params;
|
|
169
|
+
const first = effectSpec(effect).simple.find(
|
|
170
|
+
(key) => params[key]?.kind === "number",
|
|
171
|
+
);
|
|
172
|
+
if (!first) return undefined;
|
|
173
|
+
const value = parseParamValue(params[first]!, rest[0]!);
|
|
174
|
+
return value === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: { type: "fx-set", effect, values: { ...typed, [first]: value } };
|
|
177
|
+
}
|
|
178
|
+
if (rest.length === 0 || rest.length % 2 !== 0) return undefined;
|
|
179
|
+
const values: Record<string, number | string | boolean> = { ...typed };
|
|
180
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
181
|
+
const param = parseParamName(effect, rest[index]!);
|
|
182
|
+
if (!param) return undefined;
|
|
183
|
+
const value = parseParamValue(
|
|
184
|
+
effectSpec(effect).params[param]!,
|
|
185
|
+
rest[index + 1]!,
|
|
186
|
+
);
|
|
187
|
+
if (value === undefined) return undefined;
|
|
188
|
+
values[param] = value;
|
|
189
|
+
}
|
|
190
|
+
return { type: "fx-set", effect, values };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function irCommand(value: string): FxCommand | undefined {
|
|
194
|
+
const lower = value.toLowerCase();
|
|
195
|
+
if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
|
|
196
|
+
try {
|
|
197
|
+
return { type: "fx-ir", ir: normalizeReverbIr(value).src };
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Every default, optional ones included: what `fx <effect> on` stores. */
|
|
204
|
+
export function effectDefaults(effect: EffectName): FxValues {
|
|
205
|
+
const out: Record<string, number | string | boolean> = {};
|
|
206
|
+
for (const [key, spec] of Object.entries(effectSpec(effect).params))
|
|
207
|
+
// An optional switch that defaults off (orbit `shared`) stays absent.
|
|
208
|
+
if (!(spec.optional && spec.default === false)) out[key] = spec.default;
|
|
209
|
+
// A zero `time` means "follow beats"; the canonical form omits it.
|
|
210
|
+
if (effect === "delay") delete out.time;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isTrackEffect(effect: EffectName): effect is TrackEffectName {
|
|
215
|
+
return Object.prototype.hasOwnProperty.call(TRACK_EFFECT_SPECS, effect);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The effect's stored values on a track, or `undefined` when off. */
|
|
219
|
+
export function effectValues(
|
|
220
|
+
track: Track | undefined,
|
|
221
|
+
effect: EffectName,
|
|
222
|
+
): FxValues | undefined {
|
|
223
|
+
if (!track) return undefined;
|
|
224
|
+
if (isTrackEffect(effect)) return track[effect] as FxValues | undefined;
|
|
225
|
+
return track.fx?.[effect as FxName];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Patch that sets (or with `null` removes) one effect's values. */
|
|
229
|
+
export function effectPatch(
|
|
230
|
+
track: Track,
|
|
231
|
+
effect: EffectName,
|
|
232
|
+
values: FxValues | null,
|
|
233
|
+
): TrackPatch {
|
|
234
|
+
if (isTrackEffect(effect))
|
|
235
|
+
return { [effect]: values } as unknown as TrackPatch;
|
|
236
|
+
const fx: Record<string, FxValues> = { ...track.fx };
|
|
237
|
+
if (values) fx[effect] = values;
|
|
238
|
+
else delete fx[effect];
|
|
239
|
+
return { fx: Object.keys(fx).length > 0 ? fx : null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function describe(effect: EffectName, values: FxValues): string {
|
|
243
|
+
const simple = effectSpec(effect).simple;
|
|
244
|
+
return simple
|
|
245
|
+
.filter((key) => values[key] !== undefined)
|
|
246
|
+
.map((key) => `${key} ${String(values[key])}`)
|
|
247
|
+
.join(" ");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type FxResult = Readonly<{
|
|
251
|
+
ok: boolean;
|
|
252
|
+
message: string;
|
|
253
|
+
next?: TrackScore;
|
|
254
|
+
kind?: string;
|
|
255
|
+
payload?: Record<string, unknown>;
|
|
256
|
+
}>;
|
|
257
|
+
|
|
258
|
+
export function applyFxCommand(
|
|
259
|
+
score: TrackScore,
|
|
260
|
+
trackId: string,
|
|
261
|
+
command: FxCommand,
|
|
262
|
+
/** For `fx-ir` on a pack sound: the pinned reference (src, sha256, url). */
|
|
263
|
+
pinnedIr?: SampleRef,
|
|
264
|
+
): FxResult {
|
|
265
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
266
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
267
|
+
if (command.type === "fx-list") {
|
|
268
|
+
const on = EFFECT_NAMES.filter((effect) => effectValues(track, effect));
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
message: on.length
|
|
272
|
+
? `fx · ${on.map((effect) => `${effect} (${describe(effect, effectValues(track, effect)!)})`).join(" → ")}`
|
|
273
|
+
: `fx · none · chain ${FX_CHAIN.join(" → ")}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (command.type === "fx-ir")
|
|
277
|
+
return applyIr(score, track, command.ir, pinnedIr);
|
|
278
|
+
const { effect } = command;
|
|
279
|
+
const current = effectValues(track, effect);
|
|
280
|
+
let values: FxValues | null;
|
|
281
|
+
if (command.type === "fx-off") {
|
|
282
|
+
if (!current) return { ok: true, message: `${effect} · already off` };
|
|
283
|
+
values = null;
|
|
284
|
+
} else if (command.type === "fx-on") {
|
|
285
|
+
if (current) return { ok: true, message: `${effect} · already on` };
|
|
286
|
+
values = effectDefaults(effect);
|
|
287
|
+
} else if (command.type === "fx-reset") values = effectDefaults(effect);
|
|
288
|
+
else if (command.type === "fx-preset")
|
|
289
|
+
values = {
|
|
290
|
+
...effectDefaults(effect),
|
|
291
|
+
...FX_PRESETS[effect]![command.preset],
|
|
292
|
+
};
|
|
293
|
+
else values = { ...(current ?? effectDefaults(effect)), ...command.values };
|
|
294
|
+
if (values && effect === "delay" && values.time === 0) {
|
|
295
|
+
const { time: _time, ...rest } = values;
|
|
296
|
+
values = rest;
|
|
297
|
+
}
|
|
298
|
+
let next: TrackScore;
|
|
299
|
+
try {
|
|
300
|
+
next = updateTrack(score, trackId, effectPatch(track, effect, values));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (
|
|
303
|
+
error instanceof ScoreValidationError ||
|
|
304
|
+
error instanceof FxValidationError
|
|
305
|
+
)
|
|
306
|
+
return { ok: false, message: `${effect} · ${error.message}` };
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
const stored = effectValues(
|
|
310
|
+
next.tracks.find((candidate) => candidate.id === trackId),
|
|
311
|
+
effect,
|
|
312
|
+
);
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
message: stored
|
|
316
|
+
? `${effect} · ${describe(effect, stored)}`
|
|
317
|
+
: `${effect} · off`,
|
|
318
|
+
next,
|
|
319
|
+
kind: "score.effect",
|
|
320
|
+
payload: { trackId, effect, value: stored ?? null },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Sets or clears `reverb.ir`, turning the reverb on with defaults if off. */
|
|
325
|
+
function applyIr(
|
|
326
|
+
score: TrackScore,
|
|
327
|
+
track: Track,
|
|
328
|
+
ir: string | null,
|
|
329
|
+
pinned?: SampleRef,
|
|
330
|
+
): FxResult {
|
|
331
|
+
const current = track.reverb;
|
|
332
|
+
if (ir === null && !current?.ir)
|
|
333
|
+
return { ok: true, message: "reverb · no impulse response" };
|
|
334
|
+
const { ir: _old, ...rest } = (current ?? effectDefaults("reverb")) as Record<
|
|
335
|
+
string,
|
|
336
|
+
unknown
|
|
337
|
+
>;
|
|
338
|
+
const reverb =
|
|
339
|
+
ir === null
|
|
340
|
+
? rest
|
|
341
|
+
: { ...rest, ir: pinned?.src === ir ? pinned : { src: ir } };
|
|
342
|
+
let next: TrackScore;
|
|
343
|
+
try {
|
|
344
|
+
next = updateTrack(score, track.id, { reverb } as unknown as TrackPatch);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error instanceof ScoreValidationError)
|
|
347
|
+
return { ok: false, message: `reverb · ${error.message}` };
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
const stored = next.tracks.find((item) => item.id === track.id)?.reverb;
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
message: stored?.ir
|
|
354
|
+
? `reverb · ir ${stored.ir.src.replace(/^builtin:/, "")} · mix ${stored.mix}`
|
|
355
|
+
: `reverb · ir off · algorithmic tail`,
|
|
356
|
+
next,
|
|
357
|
+
kind: "score.effect",
|
|
358
|
+
payload: { trackId: track.id, effect: "reverb", value: stored ?? null },
|
|
359
|
+
};
|
|
360
|
+
}
|