@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,555 @@
1
+ /**
2
+ * The synth voice: renders one note of a track with `track.synth` (or a
3
+ * sound only the voice knows, like `supersaw` or `white`) into the dry
4
+ * buffer(s). Clean-room DSP from public documentation and standard
5
+ * literature; parameter names and meanings follow Strudel's documentation.
6
+ *
7
+ * Signal path per note, per unison voice:
8
+ *
9
+ * pitch (note · detune · vibrato · pitch envelope)
10
+ * → oscillator (resolveOscillator; phase-modulated by FM operators)
11
+ * → + pink noise (`noise`)
12
+ * summed over unison voices (panned by `spread` when stereo)
13
+ * → lpf → hpf → bpf (per note, each with its own envelope)
14
+ * → amplitude ADSR · velocity · gain · track volume
15
+ *
16
+ * Controls are read once at the note onset (stored value, or the
17
+ * `synth-<param>` lane at that tick); envelopes and LFOs run per sample,
18
+ * filter coefficients every CONTROL_SAMPLES. Everything is a pure function
19
+ * of the score and the note, so cold, cached and worker renders agree.
20
+ */
21
+ import type { Note, Track } from "../../../core/score.ts";
22
+ import type { FxLane } from "../../../core/fx.ts";
23
+ import {
24
+ LEGACY_SOUNDS,
25
+ SOUND_ALIASES,
26
+ SYNTH_PARAMS,
27
+ FM_OPERATORS,
28
+ type TrackSynth,
29
+ } from "../../../core/synth.ts";
30
+ import {
31
+ Biquad,
32
+ CONTROL_SAMPLES,
33
+ clamp,
34
+ interpolateAutomation,
35
+ } from "../effects/common.ts";
36
+ import { seededRandom } from "../random.ts";
37
+ import { isZzfxSound, renderZzfx } from "./zzfx.ts";
38
+ import {
39
+ makePinkNoise,
40
+ resolveOscillator,
41
+ type Oscillator,
42
+ type OscillatorFactory,
43
+ } from "./oscillators.ts";
44
+
45
+ /** Same base level as the legacy voice, so switching keeps loudness. */
46
+ const VOICE_LEVEL = 0.28;
47
+ const NOISE_SOUNDS = new Set(["white", "pink", "brown", "crackle"]);
48
+
49
+ /** Stored instrument name for a Strudel spelling (`sawtooth`, `sin`, …). */
50
+ export function canonicalSound(instrument: string): string {
51
+ const name = instrument.trim().toLowerCase();
52
+ return SOUND_ALIASES[name] ?? name;
53
+ }
54
+
55
+ /**
56
+ * Whether a track renders through this voice: it sets `synth`, or its
57
+ * instrument is a sound the legacy voice never had (supersaw, pulse,
58
+ * noise, user, or one added through `registerOscillatorResolver`).
59
+ * Tracks without `synth` on a legacy name keep the original voice and
60
+ * render byte-identically.
61
+ */
62
+ export function usesSynthVoice(track: Track | undefined): boolean {
63
+ if (!track) return false;
64
+ if (track.synth) return true;
65
+ const sound = canonicalSound(track.instrument);
66
+ if ((LEGACY_SOUNDS as readonly string[]).includes(sound)) return false;
67
+ return resolveOscillator(sound) !== undefined;
68
+ }
69
+
70
+ export type VoiceContext = Readonly<{
71
+ sampleRate: number;
72
+ samples: number;
73
+ samplesPerTick: number;
74
+ tempoBpm: number;
75
+ ticksPerBeat: number;
76
+ /**
77
+ * Wavetable hook: a note's oscillator when the track supplies its own
78
+ * (a wavetable track's table); otherwise the sound's resolver is used.
79
+ */
80
+ oscillatorFor?: (note: Note) => OscillatorFactory | undefined;
81
+ }>;
82
+
83
+ /** Track gain at a tick (volume × volume lane), supplied by the renderer. */
84
+ export type GainAt = (tick: number) => number;
85
+
86
+ /** Reads every control at one note's onset. */
87
+ function controls(track: Track, tick: number) {
88
+ const synth: TrackSynth = track.synth ?? {};
89
+ const lanes = track.fxAutomation;
90
+ const lane = (name: string) => lanes?.[`synth-${name}` as FxLane];
91
+ const has = (name: string): boolean =>
92
+ synth[name] !== undefined || (lane(name)?.length ?? 0) > 0;
93
+ const number = (name: string, fallback?: number): number => {
94
+ const stored = synth[name];
95
+ const base =
96
+ typeof stored === "number"
97
+ ? stored
98
+ : (fallback ?? (SYNTH_PARAMS[name]!.default as number));
99
+ const points = lane(name);
100
+ return points && points.length > 0
101
+ ? interpolateAutomation(points, tick, base)
102
+ : base;
103
+ };
104
+ const text = (name: string): string => {
105
+ const stored = synth[name];
106
+ return typeof stored === "string"
107
+ ? stored
108
+ : (SYNTH_PARAMS[name]!.default as string);
109
+ };
110
+ const list = (name: string): readonly number[] | undefined => {
111
+ const stored = synth[name];
112
+ return Array.isArray(stored) ? (stored as readonly number[]) : undefined;
113
+ };
114
+ return { has, number, text, list };
115
+ }
116
+
117
+ type Controls = ReturnType<typeof controls>;
118
+
119
+ /** Linear attack, linear decay to sustain, held until the gate closes. */
120
+ function attackDecay(t: number, a: number, d: number, s: number): number {
121
+ if (t < a) return t / a;
122
+ const u = t - a;
123
+ if (u < d) return 1 - ((1 - s) * u) / d;
124
+ return s;
125
+ }
126
+
127
+ /** An ADSR envelope; after the gate it releases linearly from its level. */
128
+ class Envelope {
129
+ private readonly gateLevel: number;
130
+ constructor(
131
+ private readonly a: number,
132
+ private readonly d: number,
133
+ private readonly s: number,
134
+ private readonly r: number,
135
+ private readonly gate: number,
136
+ ) {
137
+ this.gateLevel = attackDecay(gate, a, d, s);
138
+ }
139
+
140
+ at(t: number): number {
141
+ if (t < this.gate) return attackDecay(t, this.a, this.d, this.s);
142
+ if (this.r <= 0) return 0;
143
+ const u = t - this.gate;
144
+ return u >= this.r ? 0 : this.gateLevel * (1 - u / this.r);
145
+ }
146
+ }
147
+
148
+ function envelopeFor(
149
+ c: Controls,
150
+ prefix: string,
151
+ suffix: string,
152
+ gate: number,
153
+ ): Envelope {
154
+ return new Envelope(
155
+ c.number(`${prefix}attack${suffix}`),
156
+ c.number(`${prefix}decay${suffix}`),
157
+ c.number(`${prefix}sustain${suffix}`),
158
+ c.number(`${prefix}release${suffix}`),
159
+ gate,
160
+ );
161
+ }
162
+
163
+ /** Seconds a note sounds after its gate closes. */
164
+ export function releaseSeconds(track: Track, tick: number): number {
165
+ return controls(track, tick).number("release");
166
+ }
167
+
168
+ /** Longest release across a track's synth controls and lanes. */
169
+ export function synthTailSeconds(track: Track): number {
170
+ if (!usesSynthVoice(track)) return 0;
171
+ const stored = track.synth?.release;
172
+ let tail =
173
+ typeof stored === "number"
174
+ ? stored
175
+ : (SYNTH_PARAMS.release!.default as number);
176
+ for (const point of track.fxAutomation?.["synth-release" as FxLane] ?? [])
177
+ tail = Math.max(tail, point.value);
178
+ return tail;
179
+ }
180
+
181
+ const FILTERS = [
182
+ { prefix: "lp", type: "lpf", param: "lpf" },
183
+ { prefix: "hp", type: "hpf", param: "hpf" },
184
+ { prefix: "bp", type: "bpf", param: "bpf" },
185
+ ] as const;
186
+
187
+ const BUTTERWORTH_Q1 = 0.5412;
188
+ const BUTTERWORTH_Q2 = 1.3066;
189
+
190
+ /** One per-note filter with its envelope, for one channel. */
191
+ class NoteFilter {
192
+ private readonly first = new Biquad();
193
+ private readonly second = new Biquad();
194
+ private readonly ladder = new Float64Array(4);
195
+ private ladderG = 0;
196
+ private ladderK = 0;
197
+
198
+ constructor(
199
+ readonly type: "lpf" | "hpf" | "bpf",
200
+ readonly slope: string,
201
+ readonly cutoff: number,
202
+ readonly q: number,
203
+ readonly depth: number,
204
+ readonly anchor: number,
205
+ readonly envelope: Envelope,
206
+ private readonly sampleRate: number,
207
+ ) {}
208
+
209
+ private get isLadder(): boolean {
210
+ return this.slope === "ladder" && this.type === "lpf";
211
+ }
212
+
213
+ /** Recompute coefficients for the envelope at time `t` (seconds). */
214
+ update(t: number): void {
215
+ const e = this.depth === 0 ? 0 : this.envelope.at(t);
216
+ const frequency = clamp(
217
+ this.cutoff * 2 ** (this.depth * (e - this.anchor)),
218
+ 20,
219
+ this.sampleRate * 0.45,
220
+ );
221
+ const q = Math.max(0.1, this.q);
222
+ if (this.isLadder) {
223
+ this.ladderG = 1 - Math.exp((-2 * Math.PI * frequency) / this.sampleRate);
224
+ // Q 0.7..12 spans the ladder's feedback up to just below oscillation.
225
+ this.ladderK = 3.9 * clamp((q - 0.707) / 11.3, 0, 1);
226
+ return;
227
+ }
228
+ if (this.slope === "24db") {
229
+ this.first.set(this.type, frequency, BUTTERWORTH_Q1, this.sampleRate);
230
+ this.second.set(
231
+ this.type,
232
+ frequency,
233
+ (q * BUTTERWORTH_Q2) / 0.707,
234
+ this.sampleRate,
235
+ );
236
+ return;
237
+ }
238
+ this.first.set(this.type, frequency, q, this.sampleRate);
239
+ }
240
+
241
+ process(input: number): number {
242
+ if (this.isLadder) {
243
+ const stages = this.ladder;
244
+ const g = this.ladderG;
245
+ const driven = Math.tanh(input - this.ladderK * stages[3]!);
246
+ stages[0]! += g * (driven - stages[0]!);
247
+ stages[1]! += g * (stages[0]! - stages[1]!);
248
+ stages[2]! += g * (stages[1]! - stages[2]!);
249
+ stages[3]! += g * (stages[2]! - stages[3]!);
250
+ return stages[3]! * (1 + 0.5 * this.ladderK);
251
+ }
252
+ const once = this.first.process(input);
253
+ return this.slope === "24db" ? this.second.process(once) : once;
254
+ }
255
+ }
256
+
257
+ function noteFilters(
258
+ c: Controls,
259
+ gate: number,
260
+ sampleRate: number,
261
+ ): (() => NoteFilter)[] {
262
+ const out: (() => NoteFilter)[] = [];
263
+ for (const { prefix, type, param } of FILTERS) {
264
+ const depth = c.number(`${prefix}env`);
265
+ if (!c.has(param) && depth === 0) continue;
266
+ const cutoff = c.number(param);
267
+ const q = c.number(`${prefix}q`);
268
+ const slope = type === "lpf" ? c.text("ftype") : "12db";
269
+ const anchor = c.number("fanchor");
270
+ out.push(
271
+ () =>
272
+ new NoteFilter(
273
+ type,
274
+ slope,
275
+ cutoff,
276
+ q,
277
+ depth,
278
+ anchor,
279
+ envelopeFor(c, prefix, "", gate),
280
+ sampleRate,
281
+ ),
282
+ );
283
+ }
284
+ return out;
285
+ }
286
+
287
+ type FmOperator = Readonly<{
288
+ index: number;
289
+ ratio: number;
290
+ envelope: Envelope;
291
+ exp: boolean;
292
+ wave: string;
293
+ }>;
294
+
295
+ function fmOperators(c: Controls, gate: number): FmOperator[] {
296
+ const out: FmOperator[] = [];
297
+ for (let op = 1; op <= FM_OPERATORS; op += 1) {
298
+ const n = op === 1 ? "" : String(op);
299
+ const index = c.number(`fm${n}`);
300
+ if (index <= 0) continue;
301
+ out.push({
302
+ index,
303
+ ratio: c.number(`fmh${n}`),
304
+ envelope: envelopeFor(c, "fm", n, gate),
305
+ exp: c.text(`fmenv${n}`) === "exp",
306
+ wave: c.text(`fmwave${n}`),
307
+ });
308
+ }
309
+ return out;
310
+ }
311
+
312
+ /** A naive (unaliased-enough at modulator rates) waveform for FM. */
313
+ function modulatorWave(wave: string, phase: number): number {
314
+ const t = phase - Math.floor(phase);
315
+ if (wave === "sawtooth") return 2 * t - 1;
316
+ if (wave === "square") return t < 0.5 ? 1 : -1;
317
+ if (wave === "triangle") return 1 - 4 * Math.abs(t - 0.5);
318
+ return Math.sin(2 * Math.PI * phase);
319
+ }
320
+
321
+ /**
322
+ * Render one note. `left` always receives the voice; `right` is given
323
+ * only for stereo tracks (unison spread), in which case `left`/`right`
324
+ * hold the two channels before the (dual-mono) track chain.
325
+ */
326
+ export function renderSynthNote(
327
+ left: Float64Array,
328
+ right: Float64Array | undefined,
329
+ note: Note,
330
+ track: Track,
331
+ start: number,
332
+ length: number,
333
+ context: VoiceContext,
334
+ gainAt: GainAt,
335
+ ): void {
336
+ const { sampleRate, samples, samplesPerTick } = context;
337
+ const c = controls(track, note.startTick);
338
+ const sound = canonicalSound(track.instrument);
339
+ const gate = length / sampleRate;
340
+ const release = Math.max(0, c.number("release"));
341
+ const end = Math.min(
342
+ samples,
343
+ start + length + Math.ceil(release * sampleRate),
344
+ );
345
+ if (end <= start) return;
346
+ const random = seededRandom(`${note.id}:${note.startTick}:synth`);
347
+ const amp = envelopeFor(c, "", "", gate);
348
+ const velocity = clamp(note.velocity, 0, 1);
349
+ const gain = c.number("gain");
350
+
351
+ // Pitch.
352
+ const base = 440 * 2 ** ((note.pitch - 69) / 12);
353
+
354
+ if (isZzfxSound(sound) && !context.oscillatorFor) {
355
+ const count = end - start;
356
+ const raw = new Float64Array(count);
357
+ renderZzfx(raw, count, {
358
+ sound,
359
+ sampleRate,
360
+ frequency: base,
361
+ zrand: c.number("zrand"),
362
+ curve: c.number("curve"),
363
+ slide: c.number("slide"),
364
+ deltaSlide: c.number("deltaSlide"),
365
+ pitchJump: c.number("pitchJump"),
366
+ pitchJumpTime: c.number("pitchJumpTime"),
367
+ lfo: c.number("lfo"),
368
+ noise: c.number("noise"),
369
+ zmod: c.number("zmod"),
370
+ zcrush: c.number("zcrush"),
371
+ zdelay: c.number("zdelay"),
372
+ tremolo: c.number("tremolo"),
373
+ random,
374
+ });
375
+ const filters = noteFilters(c, gate, sampleRate).map((make) => make());
376
+ for (let elapsed = 0; elapsed < count; elapsed += 1) {
377
+ const t = elapsed / sampleRate;
378
+ if (elapsed % CONTROL_SAMPLES === 0)
379
+ for (const filter of filters) filter.update(t);
380
+ let value = raw[elapsed]!;
381
+ for (const filter of filters) value = filter.process(value);
382
+ const level =
383
+ amp.at(t) *
384
+ velocity *
385
+ gain *
386
+ VOICE_LEVEL *
387
+ gainAt(note.startTick + elapsed / samplesPerTick);
388
+ left[start + elapsed]! += value * level;
389
+ if (right) right[start + elapsed]! += value * level;
390
+ }
391
+ return;
392
+ }
393
+ const vib = c.number("vib");
394
+ const vibmod = c.number("vibmod");
395
+ const penv = c.number("penv");
396
+ const pitchEnv =
397
+ penv === 0
398
+ ? undefined
399
+ : new Envelope(
400
+ c.number("pattack"),
401
+ c.number("pdecay"),
402
+ c.number("psustain"),
403
+ c.number("prelease"),
404
+ gate,
405
+ );
406
+ const panchor = c.has("panchor") ? c.number("panchor") : c.number("psustain");
407
+ const pcurve = c.number("pcurve");
408
+
409
+ // Unison.
410
+ const supersaw = sound === "supersaw";
411
+ const unison = Math.max(
412
+ 1,
413
+ Math.round(c.has("unison") ? c.number("unison") : supersaw ? 5 : 1),
414
+ );
415
+ const detune = c.number("detune");
416
+ const spread = right ? c.number("spread") : 0;
417
+ const voiceRatios: number[] = [];
418
+ const voicePans: [number, number][] = [];
419
+ const phases: number[] = [];
420
+ for (let v = 0; v < unison; v += 1) {
421
+ const position = unison === 1 ? 0 : v / (unison - 1) - 0.5;
422
+ voiceRatios.push(2 ** ((detune * position) / 12));
423
+ const pan = clamp(spread * position * 2, -1, 1);
424
+ const theta = ((pan + 1) * Math.PI) / 4;
425
+ // Equal-power, scaled so a centred voice gets 1 in each channel.
426
+ voicePans.push([
427
+ Math.cos(theta) * Math.SQRT2,
428
+ Math.sin(theta) * Math.SQRT2,
429
+ ]);
430
+ phases.push(unison === 1 && !supersaw ? 0 : random());
431
+ }
432
+ const unisonGain = 1 / Math.sqrt(unison);
433
+
434
+ // Oscillators (one per unison voice) through the seam.
435
+ const factory =
436
+ context.oscillatorFor?.(note) ??
437
+ resolveOscillator(sound) ??
438
+ resolveOscillator("sine")!;
439
+ const pw = c.number("pw");
440
+ const pwrate = c.number("pwrate");
441
+ const pwsweep = c.number("pwsweep");
442
+ let width = pw;
443
+ const partials = c.list("partials");
444
+ const phaseList = c.list("phases");
445
+ let noteTime = 0;
446
+ const oscillators: Oscillator[] = [];
447
+ for (let v = 0; v < unison; v += 1)
448
+ oscillators.push(
449
+ factory({
450
+ sound,
451
+ sampleRate,
452
+ random,
453
+ ...(partials ? { partials } : {}),
454
+ ...(phaseList ? { phases: phaseList } : {}),
455
+ width: () => width,
456
+ density: c.number("density"),
457
+ param: (name) => track.synth?.[name],
458
+ time: () => noteTime,
459
+ }),
460
+ );
461
+ const noise = NOISE_SOUNDS.has(sound) ? 0 : c.number("noise");
462
+ const pink = noise > 0 ? makePinkNoise(random) : undefined;
463
+
464
+ const fm = fmOperators(c, gate);
465
+ const fmPhases = fm.map(() => new Float64Array(unison));
466
+ const filterMakers = noteFilters(c, gate, sampleRate);
467
+ const filtersL = filterMakers.map((make) => make());
468
+ const filtersR = right ? filterMakers.map((make) => make()) : [];
469
+
470
+ for (let index = start; index < end; index += 1) {
471
+ const elapsed = index - start;
472
+ const t = elapsed / sampleRate;
473
+ noteTime = t;
474
+ if (elapsed % CONTROL_SAMPLES === 0) {
475
+ for (const filter of filtersL) filter.update(t);
476
+ for (const filter of filtersR) filter.update(t);
477
+ }
478
+ // Instantaneous frequency multiplier from vibrato and pitch envelope.
479
+ let semitones = 0;
480
+ if (vib > 0) semitones += vibmod * Math.sin(2 * Math.PI * vib * t);
481
+ if (pitchEnv) {
482
+ let e = pitchEnv.at(t);
483
+ if (pcurve >= 1) e = e * e * e;
484
+ semitones += penv * (e - panchor);
485
+ }
486
+ const frequency = semitones === 0 ? base : base * 2 ** (semitones / 12);
487
+ if (pwsweep > 0)
488
+ width = clamp(
489
+ pw + pwsweep * 0.5 * (1 - 4 * Math.abs(((pwrate * t) % 1) - 0.5)),
490
+ 0.01,
491
+ 0.99,
492
+ );
493
+ let sampleL = 0;
494
+ let sampleR = 0;
495
+ for (let v = 0; v < unison; v += 1) {
496
+ const f = frequency * voiceRatios[v]!;
497
+ const increment = f / sampleRate;
498
+ let offset = 0;
499
+ for (let op = 0; op < fm.length; op += 1) {
500
+ const operator = fm[op]!;
501
+ const modPhases = fmPhases[op]!;
502
+ let e = operator.envelope.at(t);
503
+ if (operator.exp) e *= e;
504
+ offset +=
505
+ (operator.index * e * modulatorWave(operator.wave, modPhases[v]!)) /
506
+ (2 * Math.PI);
507
+ modPhases[v] = modPhases[v]! + increment * operator.ratio;
508
+ }
509
+ const value = oscillators[v]!(phases[v]! + offset, increment);
510
+ phases[v] = phases[v]! + increment;
511
+ if (right) {
512
+ sampleL += value * voicePans[v]![0];
513
+ sampleR += value * voicePans[v]![1];
514
+ } else sampleL += value;
515
+ }
516
+ sampleL *= unisonGain;
517
+ sampleR *= unisonGain;
518
+ if (pink) {
519
+ const n = pink();
520
+ sampleL = sampleL * (1 - noise) + n * noise;
521
+ sampleR = sampleR * (1 - noise) + n * noise;
522
+ }
523
+ for (const filter of filtersL) sampleL = filter.process(sampleL);
524
+ for (const filter of filtersR) sampleR = filter.process(sampleR);
525
+ const level =
526
+ amp.at(t) *
527
+ velocity *
528
+ gain *
529
+ VOICE_LEVEL *
530
+ gainAt(note.startTick + elapsed / samplesPerTick);
531
+ left[index]! += sampleL * level;
532
+ if (right) right[index]! += sampleR * level;
533
+ }
534
+ }
535
+
536
+ /** Whether a track's voice is stereo (unison voices spread apart). */
537
+ export function isStereoVoice(track: Track | undefined): boolean {
538
+ if (!track || !usesSynthVoice(track)) return false;
539
+ const c = controls(track, 0);
540
+ const sound = canonicalSound(track.instrument);
541
+ const unison = c.has("unison")
542
+ ? c.number("unison")
543
+ : sound === "supersaw"
544
+ ? 5
545
+ : 1;
546
+ // Lanes may raise either at a later note, so check them too.
547
+ const lanes = track.fxAutomation;
548
+ const laneMax = (name: string) =>
549
+ Math.max(
550
+ 0,
551
+ ...(lanes?.[`synth-${name}` as FxLane] ?? []).map((p) => p.value),
552
+ );
553
+ const spread = Math.max(c.number("spread"), laneMax("spread"));
554
+ return Math.max(unison, laneMax("unison")) > 1 && spread > 0;
555
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ZzFX-style procedural voice for Strudel's `z_*` sounds (`z_sine`,
3
+ * `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`).
4
+ *
5
+ * Clean-room: written from the public parameter descriptions on
6
+ * strudel.cc/learn/synths ("ZZFX") and the published meaning of the ZzFX
7
+ * controls, not from any implementation. Units are dawg's and documented
8
+ * in DAWG.md "Synth":
9
+ *
10
+ * frequency(t) = note · (1 ± zrand) + 500·slide·t + 250·deltaSlide·t²
11
+ * (+ pitchJump Hz once t ≥ pitchJumpTime)
12
+ * `lfo` > 0 restarts slide and pitchJump every `lfo` seconds and sets
13
+ * the tremolo period; `zmod` is an FM rate in Hz (±50 % depth);
14
+ * `noise` jitters the phase increment; `curve` bends the wave
15
+ * (sign·|x|^curve; 0 squares it off); `zcrush` holds samples (0..1 →
16
+ * 1..100 samples at 44.1 kHz); `zdelay` adds one echo `zdelay` seconds
17
+ * later at half level.
18
+ *
19
+ * Everything is a pure function of the note's parameters and its seeded
20
+ * PRNG, so renders are deterministic.
21
+ */
22
+ import { clamp } from "../effects/common.ts";
23
+
24
+ /** Strudel's ZzFX sound names. */
25
+ export const ZZFX_SOUNDS = Object.freeze([
26
+ "z_sine",
27
+ "z_triangle",
28
+ "z_sawtooth",
29
+ "z_square",
30
+ "z_tan",
31
+ "z_noise",
32
+ ] as const);
33
+
34
+ export function isZzfxSound(sound: string): boolean {
35
+ return (ZZFX_SOUNDS as readonly string[]).includes(sound);
36
+ }
37
+
38
+ export type ZzfxParams = Readonly<{
39
+ sound: string;
40
+ sampleRate: number;
41
+ /** Note frequency in Hz. */
42
+ frequency: number;
43
+ zrand: number;
44
+ curve: number;
45
+ slide: number;
46
+ deltaSlide: number;
47
+ pitchJump: number;
48
+ pitchJumpTime: number;
49
+ lfo: number;
50
+ noise: number;
51
+ zmod: number;
52
+ zcrush: number;
53
+ zdelay: number;
54
+ tremolo: number;
55
+ random: () => number;
56
+ }>;
57
+
58
+ function wave(sound: string, phase: number, held: number): number {
59
+ const p = phase - Math.floor(phase);
60
+ switch (sound) {
61
+ case "z_triangle":
62
+ return 1 - 4 * Math.abs(p - 0.5);
63
+ case "z_sawtooth":
64
+ return 2 * p - 1;
65
+ case "z_square":
66
+ return p < 0.5 ? 1 : -1;
67
+ case "z_tan":
68
+ return clamp(Math.tan(Math.PI * p), -1, 1);
69
+ case "z_noise":
70
+ return held;
71
+ default:
72
+ return Math.sin(2 * Math.PI * p);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Renders `count` samples of the raw voice (before the amplitude envelope,
78
+ * which the caller applies) into `out`. Waves are deliberately naive, as
79
+ * ZzFX's lo-fi character expects.
80
+ */
81
+ export function renderZzfx(
82
+ out: Float64Array,
83
+ count: number,
84
+ params: ZzfxParams,
85
+ ): void {
86
+ const { sampleRate, random } = params;
87
+ const base = params.frequency * (1 + params.zrand * (random() * 2 - 1));
88
+ const curve = Math.max(0, params.curve);
89
+ const hold = Math.max(
90
+ 1,
91
+ Math.round((1 + params.zcrush * 99) * (sampleRate / 44_100)),
92
+ );
93
+ let phase = 0;
94
+ let held = random() * 2 - 1;
95
+ let crushed = 0;
96
+ let lastCycle = 0;
97
+ for (let index = 0; index < count; index += 1) {
98
+ const t = index / sampleRate;
99
+ // Slide and pitch jump restart every `lfo` seconds.
100
+ const local = params.lfo > 0 ? t % params.lfo : t;
101
+ let frequency =
102
+ base +
103
+ 500 * params.slide * local +
104
+ 250 * params.deltaSlide * local * local;
105
+ if (params.pitchJumpTime > 0 && local >= params.pitchJumpTime)
106
+ frequency += params.pitchJump;
107
+ if (params.zmod > 0)
108
+ frequency *= 1 + 0.5 * Math.sin(2 * Math.PI * params.zmod * t);
109
+ frequency = clamp(frequency, 0, sampleRate / 2);
110
+ let increment = frequency / sampleRate;
111
+ if (params.noise > 0) increment *= 1 + params.noise * (random() * 2 - 1);
112
+ phase += increment;
113
+ // Pitched noise: a new value every half cycle.
114
+ const cycle = Math.floor(phase * 2);
115
+ if (cycle !== lastCycle) {
116
+ lastCycle = cycle;
117
+ held = random() * 2 - 1;
118
+ }
119
+ let value = wave(params.sound, phase, held);
120
+ if (curve !== 1)
121
+ value =
122
+ curve === 0
123
+ ? Math.sign(value)
124
+ : Math.sign(value) * Math.abs(value) ** curve;
125
+ if (params.tremolo > 0 && params.lfo > 0)
126
+ value *=
127
+ 1 -
128
+ params.tremolo * (0.5 + 0.5 * Math.sin((2 * Math.PI * t) / params.lfo));
129
+ if (index % hold === 0) crushed = value;
130
+ out[index] = hold > 1 ? crushed : value;
131
+ }
132
+ if (params.zdelay > 0) {
133
+ const lag = Math.round(params.zdelay * sampleRate);
134
+ for (let index = count - 1; index >= lag; index -= 1)
135
+ out[index] = out[index]! + 0.5 * out[index - lag]!;
136
+ }
137
+ }