@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. 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
+ }