@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
package/core/synth.ts ADDED
@@ -0,0 +1,1001 @@
1
+ /**
2
+ * The synth voice: per-track sound parameters stored under `track.synth`.
3
+ *
4
+ * Parameter names are Strudel's (superdough's) wherever one exists, and each
5
+ * spec lists the Strudel aliases that mean it; the mapping table is in
6
+ * DAWG.md "Synth". The DSP is dawg's own (clean-room, from public
7
+ * documentation and standard DSP literature), in `src/audio/synth/`.
8
+ *
9
+ * Unlike `track.fx`, only the parameters a document sets are stored:
10
+ * Strudel semantics, where an unset control takes its default. A track
11
+ * with no `synth` and a legacy instrument name (sine, saw, square,
12
+ * triangle, piano, pluck, bass) renders exactly as before this module.
13
+ *
14
+ * Values are per track; a `synth-<param>` automation lane is read at each
15
+ * note's onset, as Strudel reads a patterned control once per event.
16
+ */
17
+ import {
18
+ FxValidationError,
19
+ isRecord,
20
+ normalizeParams,
21
+ type NumberParam,
22
+ type ParamSpec,
23
+ } from "./params.ts";
24
+
25
+ /** Oscillator sounds of the synth voice, Strudel names first. */
26
+ export const SYNTH_SOUNDS = Object.freeze([
27
+ "sine",
28
+ "sawtooth",
29
+ "square",
30
+ "triangle",
31
+ "supersaw",
32
+ "pulse",
33
+ "user",
34
+ "white",
35
+ "pink",
36
+ "brown",
37
+ "crackle",
38
+ "z_sine",
39
+ "z_triangle",
40
+ "z_sawtooth",
41
+ "z_square",
42
+ "z_tan",
43
+ "z_noise",
44
+ ] as const);
45
+
46
+ /** dawg's original voices, kept byte-identical while `synth` is unset. */
47
+ export const LEGACY_SOUNDS = Object.freeze([
48
+ "sine",
49
+ "piano",
50
+ "pluck",
51
+ "bass",
52
+ "saw",
53
+ "square",
54
+ "triangle",
55
+ ] as const);
56
+
57
+ /** Strudel spellings accepted for an instrument, mapped to the stored name. */
58
+ export const SOUND_ALIASES: Readonly<Record<string, string>> = Object.freeze({
59
+ sin: "sine",
60
+ tri: "triangle",
61
+ sqr: "square",
62
+ supersaw: "supersaw",
63
+ pulse: "pulse",
64
+ noise: "white",
65
+ whitenoise: "white",
66
+ pinknoise: "pink",
67
+ brownnoise: "brown",
68
+ });
69
+
70
+ /** Number of FM operators (`fm`, `fm2` … `fm8`), as in Strudel. */
71
+ export const FM_OPERATORS = 8;
72
+
73
+ const FM_WAVES = ["sine", "sawtooth", "square", "triangle"] as const;
74
+ const FILTER_SLOPES = ["12db", "24db", "ladder"] as const;
75
+
76
+ const seconds = (
77
+ fallback: number,
78
+ doc: string,
79
+ strudel: readonly string[],
80
+ max = 10,
81
+ ): NumberParam => ({
82
+ kind: "number",
83
+ min: 0,
84
+ max,
85
+ default: fallback,
86
+ step: 0.01,
87
+ unit: "s",
88
+ automate: true,
89
+ doc,
90
+ strudel,
91
+ });
92
+ const level = (
93
+ fallback: number,
94
+ doc: string,
95
+ strudel: readonly string[],
96
+ ): NumberParam => ({
97
+ kind: "number",
98
+ min: 0,
99
+ max: 1,
100
+ default: fallback,
101
+ step: 0.05,
102
+ automate: true,
103
+ doc,
104
+ strudel,
105
+ });
106
+ const hz = (
107
+ fallback: number,
108
+ doc: string,
109
+ strudel: readonly string[],
110
+ ): NumberParam => ({
111
+ kind: "number",
112
+ min: 20,
113
+ max: 20_000,
114
+ default: fallback,
115
+ step: "log",
116
+ unit: "Hz",
117
+ automate: true,
118
+ doc,
119
+ strudel,
120
+ });
121
+ const q = (strudel: readonly string[]): NumberParam => ({
122
+ kind: "number",
123
+ min: 0,
124
+ max: 50,
125
+ default: 1,
126
+ step: 0.5,
127
+ automate: true,
128
+ doc: "resonance as filter Q (0..50; 0.7 is flat, higher rings)",
129
+ strudel,
130
+ });
131
+
132
+ function filterParams(
133
+ prefix: "lp" | "hp" | "bp",
134
+ name: string,
135
+ aliases: readonly string[],
136
+ qAliases: readonly string[],
137
+ ): Record<string, ParamSpec> {
138
+ const f = prefix === "lp" ? "lpf" : prefix === "hp" ? "hpf" : "bpf";
139
+ const qName = `${prefix}q`;
140
+ return {
141
+ [f]: hz(
142
+ prefix === "lp" ? 2000 : prefix === "hp" ? 200 : 1000,
143
+ `per-note ${name} cutoff; unset is no ${name}`,
144
+ [f, ...aliases],
145
+ ),
146
+ [qName]: q([qName, ...qAliases]),
147
+ [`${prefix}env`]: {
148
+ kind: "number",
149
+ min: -10,
150
+ max: 10,
151
+ default: 0,
152
+ step: 0.25,
153
+ unit: "oct",
154
+ automate: true,
155
+ doc: `${name} envelope depth in octaves above (below, negative) the cutoff`,
156
+ strudel: [`${prefix}env`, `${prefix}e`],
157
+ },
158
+ [`${prefix}attack`]: seconds(0.005, `${name} envelope attack`, [
159
+ `${prefix}attack`,
160
+ `${prefix}a`,
161
+ ]),
162
+ [`${prefix}decay`]: seconds(0.15, `${name} envelope decay`, [
163
+ `${prefix}decay`,
164
+ `${prefix}d`,
165
+ ]),
166
+ [`${prefix}sustain`]: level(0, `${name} envelope sustain level`, [
167
+ `${prefix}sustain`,
168
+ `${prefix}s`,
169
+ ]),
170
+ [`${prefix}release`]: seconds(0.1, `${name} envelope release`, [
171
+ `${prefix}release`,
172
+ `${prefix}r`,
173
+ ]),
174
+ };
175
+ }
176
+
177
+ function fmParams(): Record<string, ParamSpec> {
178
+ const out: Record<string, ParamSpec> = {};
179
+ for (let op = 1; op <= FM_OPERATORS; op += 1) {
180
+ const n = op === 1 ? "" : String(op);
181
+ const who = op === 1 ? "FM" : `FM ${op}`;
182
+ out[`fm${n}`] = {
183
+ kind: "number",
184
+ min: 0,
185
+ max: 64,
186
+ default: 0,
187
+ step: 0.25,
188
+ automate: true,
189
+ doc: `${who} modulation index (peak deviation ÷ modulator frequency); 0 is off`,
190
+ strudel: op === 1 ? ["fm", "fmi"] : [`fm${n}`, `fmi${n}`],
191
+ };
192
+ out[`fmh${n}`] = {
193
+ kind: "number",
194
+ min: 0,
195
+ max: 32,
196
+ default: 1,
197
+ step: 0.01,
198
+ automate: true,
199
+ doc: `${who} harmonicity: modulator ÷ carrier frequency (integers sound harmonic)`,
200
+ strudel: [`fmh${n}`],
201
+ };
202
+ out[`fmattack${n}`] = seconds(0, `${who} envelope attack`, [
203
+ `fmattack${n}`,
204
+ `fmatt${n}`,
205
+ ]);
206
+ out[`fmdecay${n}`] = seconds(0, `${who} envelope decay`, [
207
+ `fmdecay${n}`,
208
+ `fmdec${n}`,
209
+ ]);
210
+ out[`fmsustain${n}`] = level(1, `${who} envelope sustain level`, [
211
+ `fmsustain${n}`,
212
+ `fmsus${n}`,
213
+ ]);
214
+ out[`fmrelease${n}`] = seconds(0, `${who} envelope release`, [
215
+ `fmrelease${n}`,
216
+ `fmrel${n}`,
217
+ ]);
218
+ out[`fmenv${n}`] = {
219
+ kind: "enum",
220
+ values: ["lin", "exp"],
221
+ default: "lin",
222
+ doc: `${who} envelope curve`,
223
+ strudel: [`fmenv${n}`, `fme${n}`],
224
+ };
225
+ out[`fmwave${n}`] = {
226
+ kind: "enum",
227
+ values: FM_WAVES,
228
+ default: "sine",
229
+ doc: `${who} modulator waveform`,
230
+ strudel: [`fmwave${n}`],
231
+ };
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Every synth parameter, in display order. Groups: amplitude envelope,
238
+ * oscillator, noise, unison, pulse width, vibrato, pitch envelope,
239
+ * per-note filters with envelopes, FM operators.
240
+ */
241
+ export const SYNTH_PARAMS: Readonly<Record<string, ParamSpec>> = Object.freeze({
242
+ attack: seconds(0.003, "amplitude attack: onset to peak", ["attack", "att"]),
243
+ decay: seconds(0.05, "amplitude decay: peak to sustain level", [
244
+ "decay",
245
+ "dec",
246
+ ]),
247
+ sustain: level(1, "amplitude sustain level held until note-off", [
248
+ "sustain",
249
+ "sus",
250
+ ]),
251
+ release: seconds(0.05, "amplitude release after note-off", [
252
+ "release",
253
+ "rel",
254
+ ]),
255
+ gain: {
256
+ kind: "number",
257
+ min: 0,
258
+ max: 4,
259
+ default: 1,
260
+ step: 0.05,
261
+ automate: true,
262
+ doc: "voice gain before the effects chain (track volume follows the chain)",
263
+ strudel: ["gain"],
264
+ },
265
+ noise: level(
266
+ 0,
267
+ "pink noise mixed into the oscillator (z_* sounds: phase jitter)",
268
+ ["noise"],
269
+ ),
270
+ density: level(0.03, "crackle density (impulses ≈ density·1000/s)", [
271
+ "density",
272
+ ]),
273
+ unison: {
274
+ kind: "number",
275
+ min: 1,
276
+ max: 16,
277
+ default: 1,
278
+ step: 1,
279
+ integer: true,
280
+ doc: "stacked oscillator voices (supersaw defaults to 5)",
281
+ strudel: ["unison"],
282
+ },
283
+ detune: {
284
+ kind: "number",
285
+ min: 0,
286
+ max: 12,
287
+ default: 0.2,
288
+ step: 0.05,
289
+ unit: "st",
290
+ automate: true,
291
+ doc: "total pitch spread of the unison voices in semitones",
292
+ strudel: ["detune"],
293
+ },
294
+ spread: level(0.6, "stereo spread of the unison voices", ["spread"]),
295
+ pw: level(0.5, "pulse width (pulse sound)", ["pw"]),
296
+ pwrate: {
297
+ kind: "number",
298
+ min: 0,
299
+ max: 40,
300
+ default: 1,
301
+ step: 0.1,
302
+ unit: "Hz",
303
+ automate: true,
304
+ doc: "pulse-width LFO rate (triangle)",
305
+ strudel: ["pwrate"],
306
+ },
307
+ pwsweep: level(0, "pulse-width LFO depth", ["pwsweep"]),
308
+ vib: {
309
+ kind: "number",
310
+ min: 0,
311
+ max: 64,
312
+ default: 0,
313
+ step: 0.25,
314
+ unit: "Hz",
315
+ automate: true,
316
+ doc: "vibrato rate; 0 is off",
317
+ strudel: ["vib", "vibrato", "v"],
318
+ },
319
+ vibmod: {
320
+ kind: "number",
321
+ min: 0,
322
+ max: 24,
323
+ default: 0.5,
324
+ step: 0.05,
325
+ unit: "st",
326
+ automate: true,
327
+ doc: "vibrato depth in semitones",
328
+ strudel: ["vibmod", "vmod"],
329
+ },
330
+ penv: {
331
+ kind: "number",
332
+ min: -48,
333
+ max: 48,
334
+ default: 0,
335
+ step: 1,
336
+ unit: "st",
337
+ automate: true,
338
+ doc: "pitch envelope depth in semitones (negative inverts); 0 is off",
339
+ strudel: ["penv"],
340
+ },
341
+ pattack: seconds(0.2, "pitch envelope attack", ["pattack", "patt"]),
342
+ pdecay: seconds(0, "pitch envelope decay", ["pdecay", "pdec"]),
343
+ psustain: level(1, "pitch envelope sustain level", ["psustain", "psus"]),
344
+ prelease: seconds(0, "pitch envelope release", ["prelease", "prel"]),
345
+ pcurve: {
346
+ kind: "number",
347
+ min: 0,
348
+ max: 1,
349
+ default: 0,
350
+ step: 1,
351
+ integer: true,
352
+ doc: "pitch envelope curve: 0 linear, 1 exponential (kicks)",
353
+ strudel: ["pcurve"],
354
+ },
355
+ panchor: {
356
+ kind: "number",
357
+ min: 0,
358
+ max: 1,
359
+ default: 0,
360
+ step: 0.05,
361
+ doc: "pitch envelope anchor: 0 sweeps note→note+penv, 1 note−penv→note (defaults to psustain when unset)",
362
+ strudel: ["panchor"],
363
+ },
364
+ ...filterParams("lp", "low-pass", ["cutoff", "ctf", "lp"], ["resonance"]),
365
+ ...filterParams("hp", "high-pass", ["hcutoff", "hp"], ["hresonance"]),
366
+ ...filterParams("bp", "band-pass", ["bandf", "bp"], ["bandq"]),
367
+ ftype: {
368
+ kind: "enum",
369
+ values: FILTER_SLOPES,
370
+ default: "12db",
371
+ doc: "per-note filter slope: 12db, 24db, ladder (low-pass only)",
372
+ strudel: ["ftype"],
373
+ },
374
+ fanchor: level(
375
+ 0,
376
+ "filter envelope anchor: 0 sweeps up from the cutoff, 1 down to it",
377
+ ["fanchor"],
378
+ ),
379
+ ...fmParams(),
380
+ // ZzFX controls (z_* sounds only; src/audio/synth/zzfx.ts).
381
+ zrand: level(0, "z_*: random pitch offset per note, ± fraction", ["zrand"]),
382
+ curve: {
383
+ kind: "number",
384
+ min: 0,
385
+ max: 3,
386
+ default: 1,
387
+ step: 0.1,
388
+ automate: true,
389
+ doc: "z_*: wave shape exponent (0 squares the wave off, >1 thins it)",
390
+ strudel: ["curve"],
391
+ },
392
+ slide: {
393
+ kind: "number",
394
+ min: -20,
395
+ max: 20,
396
+ default: 0,
397
+ step: 0.1,
398
+ automate: true,
399
+ doc: "z_*: pitch slide, 500·slide Hz per second",
400
+ strudel: ["slide"],
401
+ },
402
+ deltaSlide: {
403
+ kind: "number",
404
+ min: -20,
405
+ max: 20,
406
+ default: 0,
407
+ step: 0.1,
408
+ automate: true,
409
+ doc: "z_*: slide acceleration, 500·deltaSlide Hz per second²",
410
+ strudel: ["deltaSlide", "deltaslide"],
411
+ },
412
+ pitchJump: {
413
+ kind: "number",
414
+ min: -2000,
415
+ max: 2000,
416
+ default: 0,
417
+ step: 10,
418
+ unit: "Hz",
419
+ automate: true,
420
+ doc: "z_*: pitch change applied after pitchJumpTime",
421
+ strudel: ["pitchJump", "pitchjump"],
422
+ },
423
+ pitchJumpTime: seconds(0, "z_*: time before pitchJump applies (0: never)", [
424
+ "pitchJumpTime",
425
+ "pitchjumptime",
426
+ ]),
427
+ lfo: seconds(
428
+ 0,
429
+ "z_*: repeat period: restarts slide and pitchJump, sets the tremolo period",
430
+ ["lfo"],
431
+ ),
432
+ zmod: {
433
+ kind: "number",
434
+ min: 0,
435
+ max: 1000,
436
+ default: 0,
437
+ step: 1,
438
+ unit: "Hz",
439
+ automate: true,
440
+ doc: "z_*: frequency-modulation speed (±50 % depth)",
441
+ strudel: ["zmod"],
442
+ },
443
+ zcrush: level(0, "z_*: sample-hold bit crush, 0..1", ["zcrush"]),
444
+ zdelay: seconds(
445
+ 0,
446
+ "z_*: one echo this many seconds later, half level",
447
+ ["zdelay"],
448
+ 1,
449
+ ),
450
+ tremolo: level(0, "z_*: volume modulation amount at the lfo period", [
451
+ "tremolo",
452
+ ]),
453
+ });
454
+
455
+ /** Additive harmonics (`partials`, `phases`): arrays, not knobs. */
456
+ export const SYNTH_LISTS = Object.freeze({
457
+ partials: {
458
+ doc: "amplitude of each harmonic, fundamental first (sound `user`, or any oscillator)",
459
+ min: -1,
460
+ max: 1,
461
+ },
462
+ phases: {
463
+ doc: "start phase of each harmonic in cycles 0..1",
464
+ min: 0,
465
+ max: 1,
466
+ },
467
+ } as const);
468
+
469
+ export type SynthListName = keyof typeof SYNTH_LISTS;
470
+
471
+ /** A track's synth parameters: numbers/enums by name plus optional lists. */
472
+ export type TrackSynth = Readonly<
473
+ Record<string, number | string | boolean | readonly number[]>
474
+ >;
475
+
476
+ /** Most entries `partials`/`phases` may hold. */
477
+ export const MAX_PARTIALS = 64;
478
+
479
+ /** The basic menu rows, shown before "advanced". */
480
+ export const SYNTH_SIMPLE = Object.freeze([
481
+ "attack",
482
+ "decay",
483
+ "sustain",
484
+ "release",
485
+ "lpf",
486
+ "lpq",
487
+ "lpenv",
488
+ "detune",
489
+ "vib",
490
+ "fm",
491
+ ]);
492
+
493
+ /** Menu grouping of the advanced parameters. */
494
+ export const SYNTH_GROUPS: readonly Readonly<{
495
+ id: string;
496
+ label: string;
497
+ params: readonly string[];
498
+ }>[] = Object.freeze([
499
+ {
500
+ id: "amp",
501
+ label: "amplitude",
502
+ params: ["attack", "decay", "sustain", "release", "gain"],
503
+ },
504
+ {
505
+ id: "osc",
506
+ label: "oscillator",
507
+ params: [
508
+ "noise",
509
+ "density",
510
+ "unison",
511
+ "detune",
512
+ "spread",
513
+ "pw",
514
+ "pwrate",
515
+ "pwsweep",
516
+ ],
517
+ },
518
+ { id: "vib", label: "vibrato", params: ["vib", "vibmod"] },
519
+ {
520
+ id: "pitch",
521
+ label: "pitch envelope",
522
+ params: [
523
+ "penv",
524
+ "pattack",
525
+ "pdecay",
526
+ "psustain",
527
+ "prelease",
528
+ "pcurve",
529
+ "panchor",
530
+ ],
531
+ },
532
+ ...(["lp", "hp", "bp"] as const).map((prefix) => ({
533
+ id: prefix,
534
+ label:
535
+ prefix === "lp"
536
+ ? "low-pass filter"
537
+ : prefix === "hp"
538
+ ? "high-pass filter"
539
+ : "band-pass filter",
540
+ params: [
541
+ prefix === "lp" ? "lpf" : prefix === "hp" ? "hpf" : "bpf",
542
+ `${prefix}q`,
543
+ `${prefix}env`,
544
+ `${prefix}attack`,
545
+ `${prefix}decay`,
546
+ `${prefix}sustain`,
547
+ `${prefix}release`,
548
+ ...(prefix === "lp" ? ["ftype", "fanchor"] : []),
549
+ ],
550
+ })),
551
+ ...Array.from({ length: FM_OPERATORS }, (_, index) => {
552
+ const n = index === 0 ? "" : String(index + 1);
553
+ return {
554
+ id: `fm${n || 1}`,
555
+ label: index === 0 ? "FM" : `FM ${index + 1}`,
556
+ params: [
557
+ `fm${n}`,
558
+ `fmh${n}`,
559
+ `fmattack${n}`,
560
+ `fmdecay${n}`,
561
+ `fmsustain${n}`,
562
+ `fmrelease${n}`,
563
+ `fmenv${n}`,
564
+ `fmwave${n}`,
565
+ ],
566
+ };
567
+ }),
568
+ {
569
+ id: "zzfx",
570
+ label: "ZzFX (z_* sounds)",
571
+ params: [
572
+ "zrand",
573
+ "curve",
574
+ "slide",
575
+ "deltaSlide",
576
+ "pitchJump",
577
+ "pitchJumpTime",
578
+ "lfo",
579
+ "zmod",
580
+ "zcrush",
581
+ "zdelay",
582
+ "tremolo",
583
+ ],
584
+ },
585
+ ]);
586
+
587
+ export function isSynthParam(name: string): boolean {
588
+ return Object.prototype.hasOwnProperty.call(SYNTH_PARAMS, name);
589
+ }
590
+
591
+ export function isSynthList(name: string): name is SynthListName {
592
+ return Object.prototype.hasOwnProperty.call(SYNTH_LISTS, name);
593
+ }
594
+
595
+ /** Spec name for a parameter typed as its name or any Strudel alias. */
596
+ export function synthParamName(name: string): string | undefined {
597
+ const lower = name.toLowerCase();
598
+ if (isSynthParam(lower) || isSynthList(lower)) return lower;
599
+ for (const [key, spec] of Object.entries(SYNTH_PARAMS))
600
+ if (spec.strudel?.some((alias) => alias.toLowerCase() === lower))
601
+ return key;
602
+ return undefined;
603
+ }
604
+
605
+ /** Synth parameters with a `synth-<param>` lane (read at note onsets). */
606
+ export const SYNTH_LANE_PARAMS: readonly Readonly<{
607
+ param: string;
608
+ spec: NumberParam;
609
+ }>[] = Object.freeze(
610
+ Object.entries(SYNTH_PARAMS)
611
+ .filter(
612
+ (entry): entry is [string, NumberParam] =>
613
+ entry[1].kind === "number" && entry[1].automate === true,
614
+ )
615
+ .map(([param, spec]) => Object.freeze({ param, spec })),
616
+ );
617
+
618
+ /**
619
+ * Validates `track.synth`: known names only, numbers in range, lists of at
620
+ * most MAX_PARTIALS finite numbers. Keys come back in spec order (lists
621
+ * last) so equal documents print equally; `{}` and `null` mean none.
622
+ */
623
+ export function normalizeSynth(input: unknown): TrackSynth | undefined {
624
+ if (input === undefined || input === null) return undefined;
625
+ if (!isRecord(input))
626
+ throw new FxValidationError("track synth must be an object or null");
627
+ const scalars: Record<string, unknown> = {};
628
+ const lists: Record<string, readonly number[]> = {};
629
+ for (const [key, value] of Object.entries(input)) {
630
+ if (value === undefined || value === null) continue;
631
+ if (isSynthList(key)) {
632
+ lists[key] = normalizeList(key, value);
633
+ continue;
634
+ }
635
+ if (!isSynthParam(key))
636
+ throw new FxValidationError(
637
+ `synth has no parameter "${key}"${suggest(key)}`,
638
+ );
639
+ scalars[key] = value;
640
+ }
641
+ const values = normalizeParams(SYNTH_PARAMS, scalars, "synth", false);
642
+ const out: Record<string, number | string | boolean | readonly number[]> = {
643
+ ...values,
644
+ };
645
+ for (const name of Object.keys(SYNTH_LISTS))
646
+ if (lists[name]) out[name] = lists[name];
647
+ return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
648
+ }
649
+
650
+ function suggest(key: string): string {
651
+ const name = synthParamName(key);
652
+ return name && name !== key ? ` (did you mean "${name}"?)` : "";
653
+ }
654
+
655
+ function normalizeList(name: SynthListName, value: unknown): readonly number[] {
656
+ const { min, max } = SYNTH_LISTS[name];
657
+ if (!Array.isArray(value))
658
+ throw new FxValidationError(`synth ${name} must be an array of numbers`);
659
+ if (value.length === 0 || value.length > MAX_PARTIALS)
660
+ throw new FxValidationError(
661
+ `synth ${name} holds 1 to ${MAX_PARTIALS} numbers`,
662
+ );
663
+ for (const item of value)
664
+ if (
665
+ typeof item !== "number" ||
666
+ !Number.isFinite(item) ||
667
+ item < min ||
668
+ item > max
669
+ )
670
+ throw new FxValidationError(
671
+ `synth ${name} entries must be numbers between ${min} and ${max}`,
672
+ );
673
+ return Object.freeze([...(value as number[])]);
674
+ }
675
+
676
+ /** A stored value, or the spec default. */
677
+ export function synthValue(
678
+ synth: TrackSynth | undefined,
679
+ name: string,
680
+ ): number | string | boolean {
681
+ const value = synth?.[name];
682
+ if (value !== undefined && !Array.isArray(value))
683
+ return value as number | string | boolean;
684
+ return SYNTH_PARAMS[name]!.default;
685
+ }
686
+
687
+ /**
688
+ * Named starting points (`synth preset <name>`): an instrument plus the
689
+ * parameters that make the sound. Loading one replaces `synth`.
690
+ */
691
+ export const SYNTH_PRESETS: Readonly<
692
+ Record<
693
+ string,
694
+ Readonly<{ instrument: string; doc: string; synth: TrackSynth }>
695
+ >
696
+ > = Object.freeze({
697
+ pad: {
698
+ instrument: "supersaw",
699
+ doc: "slow, wide detuned saws through a soft low-pass",
700
+ synth: {
701
+ attack: 0.6,
702
+ decay: 0.5,
703
+ sustain: 0.8,
704
+ release: 1.2,
705
+ unison: 6,
706
+ detune: 0.25,
707
+ spread: 0.8,
708
+ lpf: 1800,
709
+ lpq: 0.8,
710
+ gain: 0.7,
711
+ },
712
+ },
713
+ lead: {
714
+ instrument: "sawtooth",
715
+ doc: "bright saw with a short filter blip and delayed vibrato feel",
716
+ synth: {
717
+ attack: 0.005,
718
+ decay: 0.2,
719
+ sustain: 0.7,
720
+ release: 0.12,
721
+ lpf: 1400,
722
+ lpq: 4,
723
+ lpenv: 2.5,
724
+ lpdecay: 0.25,
725
+ lpsustain: 0.3,
726
+ vib: 5.5,
727
+ vibmod: 0.15,
728
+ unison: 2,
729
+ detune: 0.08,
730
+ },
731
+ },
732
+ pluck: {
733
+ instrument: "pulse",
734
+ doc: "short percussive pulse with a fast filter envelope",
735
+ synth: {
736
+ attack: 0.001,
737
+ decay: 0.25,
738
+ sustain: 0,
739
+ release: 0.1,
740
+ pw: 0.35,
741
+ lpf: 600,
742
+ lpq: 2,
743
+ lpenv: 4,
744
+ lpdecay: 0.12,
745
+ lpsustain: 0,
746
+ },
747
+ },
748
+ bass: {
749
+ instrument: "sawtooth",
750
+ doc: "round saw bass, 24 dB low-pass with a little bite",
751
+ synth: {
752
+ attack: 0.002,
753
+ decay: 0.3,
754
+ sustain: 0.6,
755
+ release: 0.06,
756
+ lpf: 300,
757
+ lpq: 2,
758
+ lpenv: 2,
759
+ lpdecay: 0.15,
760
+ lpsustain: 0.1,
761
+ ftype: "24db",
762
+ },
763
+ },
764
+ sub: {
765
+ instrument: "sine",
766
+ doc: "clean sine sub with a tiny pitch drop on each note",
767
+ synth: {
768
+ attack: 0.004,
769
+ sustain: 1,
770
+ release: 0.08,
771
+ penv: 3,
772
+ pattack: 0,
773
+ pdecay: 0.04,
774
+ psustain: 0,
775
+ panchor: 0,
776
+ },
777
+ },
778
+ acid: {
779
+ instrument: "sawtooth",
780
+ doc: "ladder low-pass with high resonance and a snappy envelope",
781
+ synth: {
782
+ attack: 0.002,
783
+ decay: 0.2,
784
+ sustain: 0.5,
785
+ release: 0.05,
786
+ lpf: 400,
787
+ lpq: 18,
788
+ lpenv: 3.5,
789
+ lpdecay: 0.18,
790
+ lpsustain: 0,
791
+ ftype: "ladder",
792
+ },
793
+ },
794
+ keys: {
795
+ instrument: "sine",
796
+ doc: "electric-piano style 1:1 FM with a decaying modulator",
797
+ synth: {
798
+ attack: 0.002,
799
+ decay: 1.2,
800
+ sustain: 0.25,
801
+ release: 0.3,
802
+ fm: 2.2,
803
+ fmh: 1,
804
+ fmdecay: 0.6,
805
+ fmsustain: 0.1,
806
+ fm2: 0.4,
807
+ fmh2: 14,
808
+ fmdecay2: 0.05,
809
+ fmsustain2: 0,
810
+ },
811
+ },
812
+ bell: {
813
+ instrument: "sine",
814
+ doc: "inharmonic FM bell with a long ring",
815
+ synth: {
816
+ attack: 0.001,
817
+ decay: 2.5,
818
+ sustain: 0,
819
+ release: 1.5,
820
+ fm: 4,
821
+ fmh: 3.5,
822
+ fmdecay: 1.8,
823
+ fmsustain: 0,
824
+ },
825
+ },
826
+ organ: {
827
+ instrument: "user",
828
+ doc: "drawbar-style additive organ with a gentle vibrato",
829
+ synth: {
830
+ attack: 0.01,
831
+ sustain: 1,
832
+ release: 0.08,
833
+ vib: 6,
834
+ vibmod: 0.08,
835
+ partials: [1, 0.8, 0.6, 0.5, 0, 0.35, 0, 0.3],
836
+ },
837
+ },
838
+ strings: {
839
+ instrument: "supersaw",
840
+ doc: "softer ensemble: slow attack, gentle vibrato, darker filter",
841
+ synth: {
842
+ attack: 0.35,
843
+ decay: 0.3,
844
+ sustain: 0.85,
845
+ release: 0.8,
846
+ unison: 4,
847
+ detune: 0.15,
848
+ spread: 0.7,
849
+ lpf: 2500,
850
+ vib: 5,
851
+ vibmod: 0.1,
852
+ gain: 0.75,
853
+ },
854
+ },
855
+ brass: {
856
+ instrument: "sawtooth",
857
+ doc: "filter swell on attack like a brass section",
858
+ synth: {
859
+ attack: 0.06,
860
+ decay: 0.3,
861
+ sustain: 0.8,
862
+ release: 0.15,
863
+ lpf: 700,
864
+ lpq: 1.5,
865
+ lpenv: 2.5,
866
+ lpattack: 0.08,
867
+ lpdecay: 0.4,
868
+ lpsustain: 0.5,
869
+ unison: 2,
870
+ detune: 0.1,
871
+ },
872
+ },
873
+ wind: {
874
+ instrument: "pink",
875
+ doc: "breathy band-passed noise that swells and fades",
876
+ synth: {
877
+ attack: 0.4,
878
+ sustain: 1,
879
+ release: 0.6,
880
+ gain: 2.5,
881
+ bpf: 900,
882
+ bpq: 2,
883
+ bpenv: 1,
884
+ bpattack: 0.5,
885
+ bpsustain: 0.5,
886
+ },
887
+ },
888
+ chip: {
889
+ instrument: "pulse",
890
+ doc: "8-bit square lead with slow pulse-width motion",
891
+ synth: {
892
+ attack: 0.001,
893
+ sustain: 0.8,
894
+ release: 0.03,
895
+ pw: 0.25,
896
+ pwrate: 0.8,
897
+ pwsweep: 0.2,
898
+ },
899
+ },
900
+ zap: {
901
+ instrument: "z_square",
902
+ doc: "ZzFX laser zap: a square that dives in pitch",
903
+ synth: {
904
+ attack: 0.001,
905
+ decay: 0.12,
906
+ sustain: 0.2,
907
+ release: 0.08,
908
+ slide: -4,
909
+ curve: 0.6,
910
+ },
911
+ },
912
+ });
913
+
914
+ export function isSynthPreset(name: string): boolean {
915
+ return Object.prototype.hasOwnProperty.call(SYNTH_PRESETS, name);
916
+ }
917
+
918
+ /**
919
+ * The ZzFX positional parameter layout (ZzFX README, MIT): `zzfx(...[volume,
920
+ * randomness, frequency, attack, sustain, release, shape, shapeCurve, slide,
921
+ * deltaSlide, pitchJump, pitchJumpTime, repeatTime, noise, modulation,
922
+ * bitCrush, delay, sustainVolume, decay, tremolo, filter])`. Each entry is
923
+ * the dawg synth parameter it sets; `null` entries are taken from the note
924
+ * (`frequency` is the note's pitch, `sustain` time its length).
925
+ */
926
+ export const ZZFX_ARRAY_LAYOUT = Object.freeze([
927
+ "gain",
928
+ "zrand",
929
+ null,
930
+ "attack",
931
+ null,
932
+ "release",
933
+ "shape",
934
+ "curve",
935
+ "slide",
936
+ "deltaSlide",
937
+ "pitchJump",
938
+ "pitchJumpTime",
939
+ "lfo",
940
+ "noise",
941
+ "zmod",
942
+ "zcrush",
943
+ "zdelay",
944
+ "sustain",
945
+ "decay",
946
+ "tremolo",
947
+ "filter",
948
+ ] as const);
949
+
950
+ /** ZzFX `shape` 0..5 as dawg's z_* sounds. */
951
+ const ZZFX_SHAPES = [
952
+ "z_sine",
953
+ "z_triangle",
954
+ "z_sawtooth",
955
+ "z_tan",
956
+ "z_noise",
957
+ "z_square",
958
+ ] as const;
959
+
960
+ /** ZzFX's defaults where they differ from an unset dawg parameter. */
961
+ const ZZFX_ARRAY_DEFAULTS: Readonly<Record<string, number>> = {
962
+ zrand: 0.05,
963
+ attack: 0,
964
+ release: 0.1,
965
+ };
966
+
967
+ /**
968
+ * A raw ZzFX parameter array (Strudel `zzfx([...])`) as a `z_*` instrument
969
+ * and synth parameters. Empty slots (`undefined`/`null`) take ZzFX's
970
+ * defaults; values outside dawg's ranges are rejected like any synth
971
+ * parameter. `filter` > 0 is a high-pass at that many Hz, < 0 a low-pass.
972
+ * core/sdk/v1.ts `zzfx()` mirrors this mapping.
973
+ */
974
+ export function zzfxArraySynth(
975
+ values: readonly (number | null | undefined)[],
976
+ ): { instrument: string; synth: TrackSynth } {
977
+ if (values.length > ZZFX_ARRAY_LAYOUT.length)
978
+ throw new FxValidationError(
979
+ `zzfx takes at most ${ZZFX_ARRAY_LAYOUT.length} values`,
980
+ );
981
+ const synth: Record<string, number> = { ...ZZFX_ARRAY_DEFAULTS };
982
+ let instrument: string = ZZFX_SHAPES[0];
983
+ ZZFX_ARRAY_LAYOUT.forEach((name, index) => {
984
+ const value = values[index];
985
+ if (name === null || value === undefined || value === null) return;
986
+ if (!Number.isFinite(value))
987
+ throw new FxValidationError(`zzfx value ${index} must be a number`);
988
+ if (name === "shape") {
989
+ instrument =
990
+ ZZFX_SHAPES[Math.max(0, Math.min(5, Math.round(value)))] ?? instrument;
991
+ return;
992
+ }
993
+ if (name === "filter") {
994
+ if (value === 0) return;
995
+ synth[value > 0 ? "hpf" : "lpf"] = Math.abs(value);
996
+ return;
997
+ }
998
+ synth[name] = value;
999
+ });
1000
+ return { instrument, synth: normalizeSynth(synth) ?? Object.freeze({}) };
1001
+ }