synth.mjsannotatedsynth.mjssource567 lines · 15.6 KB · raw
1import { clamp } from './util.mjs';
2import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs';
3import { getAudioContext } from './audioContext.mjs';
4import {
5  applyFM,
6  gainNode,
7  getADSRValues,
8  getFrequencyFromValue,
9  getLfo,
10  getParamADSR,
11  getPitchEnvelope,
12  getVibratoOscillator,
13  getWorklet,
14  noises,
15  onceEnded,
16  releaseAudioNode,
17  webAudioTimeout,
18} from './helpers.mjs';
19import { logger } from './logger.mjs';
20import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
21import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs';
22
23const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
24const waveformAliases = [
25  ['tri', 'triangle'],
26  ['sqr', 'square'],
27  ['saw', 'sawtooth'],
28  ['sin', 'sine'],
29];
30
31function makeSaturationCurve(amount, n_samples) {
32  const k = typeof amount === 'number' ? amount : 50;
33  const curve = new Float32Array(n_samples);
34
35  for (let i = 0; i < n_samples; i++) {
36    const x = (i * 2) / n_samples - 1;
37    curve[i] = Math.tanh(x * k);
38  }
39  return curve;
40}
41
42export function registerSynthSounds() {
43  [...waveforms].forEach((s) => {
44    registerSound(
45      s,
46      (t, value, onended) => {
47        const [attack, decay, sustain, release] = getADSRValues(
48          [value.attack, value.decay, value.sustain, value.release],
49          'linear',
50          [0.001, 0.05, 0.6, 0.01],
51        );
52
53        // turn down
54        const g = gainNode(0.3);
55
56        const sound = getOscillator(s, t, value, () => {
57          releaseAudioNode(g);
58          onended();
59        });
60
61        const { node: o, nodes, stop, triggerRelease } = sound;
62
63        const { duration } = value;
64
65        const envGain = gainNode(1);
66        const node = o.connect(g).connect(envGain);
67        const holdEnd = t + duration;
68        getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
69        const envEnd = holdEnd + release + 0.01;
70        triggerRelease?.(envEnd);
71        stop(envEnd);
72        return {
73          node,
74          nodes,
75          stop: (endTime) => {
76            stop(endTime);
77          },
78        };
79      },
80      { type: 'synth', prebake: true },
81    );
82  });
83
84  registerSound(
85    'sbd',
86    (t, value, onended) => {
87      const { duration, decay = 0.5, pdecay = 0.5, penv = 36, clip } = value;
88      const ctx = getAudioContext();
89      const attackhold = 0.02;
90      const noiselvl = 1.2;
91      const noisedecay = 0.025;
92      const mixGain = 1;
93
94      const o = ctx.createOscillator();
95      o.type = 'triangle';
96      o.frequency.value = getFrequencyFromValue(value, 29);
97      o.detune.setValueAtTime(penv * 100, 0);
98      o.detune.setValueAtTime(penv * 100, t);
99      o.detune.exponentialRampToValueAtTime(0.001, t + pdecay);
100      const g = gainNode(1);
101      g.gain.setValueAtTime(1, t + attackhold);
102      g.gain.exponentialRampToValueAtTime(0.001, t + attackhold + decay);
103      o.start(t);
104
105      const noise = getNoiseOscillator('brown', t, 2);
106      const noiseGain = gainNode(1);
107      noiseGain.gain.setValueAtTime(noiselvl, t);
108      noiseGain.gain.exponentialRampToValueAtTime(0.001, t + noisedecay);
109
110      const sat = new WaveShaperNode(ctx);
111      // tri to sine diode shaper emulation
112      sat.curve = makeSaturationCurve(2, ctx.sampleRate);
113
114      const mix = gainNode(mixGain);
115
116      onceEnded(o, () => {
117        releaseAudioNode(o);
118        releaseAudioNode(g);
119        releaseAudioNode(sat);
120        releaseAudioNode(noise.node);
121        releaseAudioNode(noiseGain);
122        releaseAudioNode(mix);
123        onended();
124      });
125
126      const node = o.connect(sat).connect(g).connect(mix);
127      noise.node.connect(noiseGain).connect(mix);
128
129      const holdEnd = t + decay;
130      let end = holdEnd + 0.01;
131      if (clip != null) {
132        end = Math.min(t + clip * duration, end);
133      }
134
135      // prevent clicking
136      mix.gain.setValueAtTime(mixGain, end - 0.01);
137      mix.gain.linearRampToValueAtTime(0, end);
138
139      o.stop(end);
140      noise.stop(end);
141
142      return {
143        node,
144        nodes: { source: [o] },
145        stop: (endTime) => {
146          o.stop(endTime);
147        },
148      };
149    },
150    { type: 'synth', prebake: true },
151  );
152
153  registerSound(
154    'supersaw',
155    (begin, value, onended) => {
156      const ac = getAudioContext();
157      let { duration, n, unison = 5, spread = 0.6, detune } = value;
158      detune = detune ?? n ?? 0.18;
159      const frequency = getFrequencyFromValue(value);
160
161      const [attack, decay, sustain, release] = getADSRValues(
162        [value.attack, value.decay, value.sustain, value.release],
163        'linear',
164        [0.001, 0.05, 0.6, 0.01],
165      );
166
167      const holdend = begin + duration;
168      const end = holdend + release + 0.01;
169      const voices = clamp(unison, 1, 100);
170      let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
171      const params = {
172        frequency,
173        begin,
174        end,
175        freqspread: detune,
176        voices,
177        panspread,
178      };
179      const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] });
180      const o = getNodeFromPool('supersaw', factory);
181      Object.entries(params).forEach(([key, value]) => {
182        const param = o.parameters.get(key);
183        const target = value !== undefined ? value : param.defaultValue;
184        param.value = target;
185      });
186      o.port.postMessage({ type: 'initialize' });
187      const gainAdjustment = 1 / Math.sqrt(voices);
188      getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
189      const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
190      const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
191      let envGain = gainNode(1);
192      envGain = o.connect(envGain);
193
194      getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
195
196      let timeoutNode = webAudioTimeout(
197        ac,
198        () => {
199          releaseNodeToPool(o);
200          onended();
201          fmHandle?.stop();
202          vibratoHandle?.stop();
203        },
204        begin,
205        end,
206      );
207
208      return {
209        node: envGain,
210        nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
211        stop: (time) => {
212          timeoutNode.stop(time);
213        },
214      };
215    },
216    { prebake: true, type: 'synth' },
217  );
218
219  registerSound(
220    'bytebeat',
221    (begin, value, onended) => {
222      const defaultBeats = [
223        '(t%255 >= t/255%255)*255',
224        '(t*(t*8%60 <= 300)|(-t)*(t*4%512 < 256))+t/400',
225        't',
226        't*(t >> 10^t)',
227        't&128',
228        't&t>>8',
229        '((t%255+t%128+t%64+t%32+t%16+t%127.8+t%64.8+t%32.8+t%16.8)/3)',
230        '((t%64+t%63.8+t%64.15+t%64.35+t%63.5)/1.25)',
231        '(t&(t>>7)-t)',
232        '(sin(t*PI/128)*127+127)',
233        '((t^t/2+t+64*(sin((t*PI/64)+(t*PI/32768))+64))%128*2)',
234        '((t^t/2+t+64*(cos >> 0))%127.85*2)',
235        '((t^t/2+t+64)%128*2)',
236        '(((t * .25)^(t * .25)/100+(t * .25))%128)*2',
237        '((t^t/2+t+64)%7 * 24)',
238      ];
239      const { n = 0 } = value;
240      const frequency = getFrequencyFromValue(value);
241      const { byteBeatExpression = defaultBeats[n % defaultBeats.length], byteBeatStartTime } = value;
242
243      const ac = getAudioContext();
244
245      let { duration } = value;
246      const [attack, decay, sustain, release] = getADSRValues(
247        [value.attack, value.decay, value.sustain, value.release],
248        'linear',
249        [0.001, 0.05, 0.6, 0.01],
250      );
251      const holdend = begin + duration;
252      const end = holdend + release + 0.01;
253
254      let o = getWorklet(
255        ac,
256        'byte-beat-processor',
257        {
258          frequency,
259          begin,
260          end,
261        },
262        {
263          outputChannelCount: [2],
264        },
265      );
266
267      o.port.postMessage({ codeText: byteBeatExpression, byteBeatStartTime, frequency });
268
269      let envGain = gainNode(1);
270      envGain = o.connect(envGain);
271
272      getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
273
274      let timeoutNode = webAudioTimeout(
275        ac,
276        () => {
277          releaseAudioNode(o);
278          onended();
279        },
280        begin,
281        end,
282      );
283
284      return {
285        node: envGain,
286        source: o,
287        stop: (time) => {
288          timeoutNode.stop(time);
289        },
290      };
291    },
292    { prebake: true, type: 'synth' },
293  );
294
295  registerSound(
296    'pulse',
297    (begin, value, onended) => {
298      const ac = getAudioContext();
299      let { pwrate, pwsweep } = value;
300      if (pwsweep == null) {
301        if (pwrate != null) {
302          pwsweep = 0.3;
303        } else {
304          pwsweep = 0;
305        }
306      }
307
308      if (pwrate == null && pwsweep != null) {
309        pwrate = 1;
310      }
311
312      let { duration, pw: pulsewidth = 0.5 } = value;
313      const frequency = getFrequencyFromValue(value);
314
315      const [attack, decay, sustain, release] = getADSRValues(
316        [value.attack, value.decay, value.sustain, value.release],
317        'linear',
318        [0.001, 0.05, 0.6, 0.01],
319      );
320      const holdend = begin + duration;
321      const end = holdend + release + 0.01;
322      let o = getWorklet(
323        ac,
324        'pulse-oscillator',
325        {
326          frequency,
327          begin,
328          end,
329          pulsewidth,
330        },
331        {
332          outputChannelCount: [2],
333        },
334      );
335
336      getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
337      const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
338      const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
339      let envGain = gainNode(1);
340      envGain = o.connect(envGain);
341
342      getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
343      let pw_lfo;
344      if (pwsweep != 0) {
345        pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
346        pw_lfo.connect(o.parameters.get('pulsewidth'));
347      }
348      let timeoutNode = webAudioTimeout(
349        ac,
350        () => {
351          releaseAudioNode(o);
352          releaseAudioNode(pw_lfo);
353          onended();
354          fmHandle?.stop();
355          vibratoHandle?.stop();
356        },
357        begin,
358        end,
359      );
360
361      return {
362        node: envGain,
363        nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
364        stop: (time) => {
365          timeoutNode.stop(time);
366        },
367      };
368    },
369    { prebake: true, type: 'synth' },
370  );
371
372  registerSound(
373    'bus',
374    (begin, value, onended) => {
375      const ac = getAudioContext();
376      const [attack, decay, sustain, release] = getADSRValues(
377        [value.attack, value.decay, value.sustain, value.release],
378        'linear',
379        [0.001, 0.05, 1, 0.01],
380      );
381      const holdend = begin + value.duration;
382      const end = holdend + release + 0.01;
383      const bus = getSuperdoughAudioController().getBus(value.n ?? 0);
384      const envGain = bus.connect(gainNode(0));
385      getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
386      const timeoutNode = webAudioTimeout(
387        ac,
388        () => {
389          bus.disconnect(envGain);
390          onended();
391        },
392        begin,
393        end,
394      );
395
396      return {
397        node: envGain,
398        nodes: { source: [bus] },
399        stop: (time) => {
400          timeoutNode.stop(time);
401        },
402      };
403    },
404    { prebake: true, type: 'input' },
405  );
406
407  [...noises].forEach((s) => {
408    registerSound(
409      s,
410      (t, value, onended) => {
411        const [attack, decay, sustain, release] = getADSRValues(
412          [value.attack, value.decay, value.sustain, value.release],
413          'linear',
414          [0.001, 0.05, 0.6, 0.01],
415        );
416
417        let sound;
418
419        let { density } = value;
420        sound = getNoiseOscillator(s, t, density);
421
422        let { node: o, stop, triggerRelease } = sound;
423
424        // turn down
425        const g = gainNode(0.3);
426
427        const { duration } = value;
428
429        onceEnded(o, () => {
430          releaseAudioNode(o);
431          releaseAudioNode(g);
432          onended();
433        });
434
435        const envGain = gainNode(1);
436        let node = o.connect(g).connect(envGain);
437        const holdEnd = t + duration;
438        getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
439        const envEnd = holdEnd + release + 0.01;
440        triggerRelease?.(envEnd);
441        stop(envEnd);
442        return {
443          node,
444          nodes: { source: [o] },
445          stop: (endTime) => {
446            stop(endTime);
447          },
448        };
449      },
450      { type: 'synth', prebake: true },
451    );
452  });
453  waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
454}
455
456const PI2 = 2 * Math.PI;
457export function waveformN(partials, phases, type) {
458  const isList = typeof partials === 'object';
459  partials = isList ? partials : new Float32Array(partials).fill(1);
460  const len = partials.length;
461  const real = new Float32Array(len + 1);
462  const imag = new Float32Array(len + 1);
463  const ac = getAudioContext();
464  const osc = ac.createOscillator();
465
466  const terms = {
467    sawtooth: (n) => [0, -1 / n],
468    square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
469    triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
470    user: (_n) => [0, 1],
471  };
472
473  if (!terms[type]) {
474    throw new Error(`unknown wave type ${type}`);
475  }
476
477  for (let n = 0; n < len; n++) {
478    const mag = partials[n];
479    const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
480    const phase = phases?.[n] ?? 0;
481    // Scale by `partials`
482    let R = r * mag;
483    let I = i * mag;
484    // Apply rotation by the phase
485    if (phase !== 0) {
486      const c = Math.cos(PI2 * phase);
487      const s = Math.sin(PI2 * phase);
488      const R0 = R;
489      const I0 = I;
490      R = c * R0 - s * I0;
491      I = s * R0 + c * I0;
492    }
493    real[n + 1] = R;
494    imag[n + 1] = I;
495  }
496
497  const wave = ac.createPeriodicWave(real, imag);
498  osc.setPeriodicWave(wave);
499  return osc;
500}
501
502// expects one of waveforms as s
503export function getOscillator(s, t, value, onended) {
504  const { duration, noise = 0 } = value;
505  const partials = value.partials ?? value.n;
506  let o;
507  if (s === 'user' && !partials) {
508    logger(
509      `[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
510    );
511    s = 'triangle';
512  }
513  s = s === 'user' && !partials ? 'triangle' : s;
514  if (s === 'one') {
515    // Constant 1 oscillator (used for modulation)
516    o = new ConstantSourceNode(getAudioContext(), { offset: 1 });
517    o.start(t);
518    return {
519      node: o,
520      nodes: { source: o },
521      stop: (time) => o?.stop(time),
522    };
523  } else if (!partials || partials?.length === 0 || s === 'sine') {
524    // If no partials are given, use stock waveforms
525    o = getAudioContext().createOscillator();
526    o.type = s || 'triangle';
527  }
528  // generate custom waveform if partials are given
529  else {
530    o = waveformN(partials, value.phases, s);
531  }
532  // set frequency
533  o.frequency.value = getFrequencyFromValue(value);
534
535  const vibratoHandle = getVibratoOscillator(o.detune, value, t);
536
537  // pitch envelope
538  getPitchEnvelope(o.detune, value, t, t + duration);
539  const fmHandle = applyFM(o.frequency, value, t);
540
541  let noiseMix;
542  if (noise) {
543    noiseMix = getNoiseMix(o, noise, t);
544  }
545
546  onceEnded(o, () => {
547    noiseMix?.teardown();
548    releaseAudioNode(o);
549    releaseAudioNode(noiseMix?.node);
550    onended();
551  });
552  o.start(t);
553
554  return {
555    node: noiseMix?.node || o,
556    nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes },
557    stop: (time) => {
558      fmHandle.stop(time);
559      vibratoHandle?.stop(time);
560      noiseMix?.stop(time);
561      o.stop(time);
562    },
563    triggerRelease: (time) => {
564      // envGain?.stop(time);
565    },
566  };
567}