// this is dough, the superdough without dependencies
// @ts-nocheck
// @ts-ignore ignore next line because sampleRate is unknown
const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000;
const PI_DIV_SR = Math.PI / SAMPLE_RATE;
const ISR = 1 / SAMPLE_RATE;

let gainCurveFunc = (val) => Math.pow(val, 2);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

function applyGainCurve(val) {
  return gainCurveFunc(val);
}

/**
 * Equal Power Crossfade function.
 * Smoothly transitions between signals A and B, maintaining consistent perceived loudness.
 *
 * @param {number} a - Signal A (can be a single value or an array value in buffer processing).
 * @param {number} b - Signal B (can be a single value or an array value in buffer processing).
 * @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix).
 * @tags internals
 * @returns {number} Crossfaded output value.
 */
function crossfade(a, b, m) {
  const aGain = Math.sin((1 - m) * 0.5 * Math.PI);
  const bGain = Math.sin(m * 0.5 * Math.PI);
  return a * aGain + b * bGain;
}

// function setGainCurve(newGainCurveFunc) {
//   gainCurveFunc = newGainCurveFunc;
// }
// https://garten.salat.dev/audio-DSP/oscillators.html
export class SineOsc {
  phase = 0;
  update(freq) {
    const value = Math.sin(this.phase * 2 * Math.PI);
    this.phase = (this.phase + freq / SAMPLE_RATE) % 1;
    return value;
  }
}

export class ZawOsc {
  phase = 0;
  update(freq) {
    this.phase += ISR * freq;
    return (this.phase % 1) * 2 - 1;
  }
}

function polyBlep(t, dt) {
  // 0 <= t < 1
  if (t < dt) {
    t /= dt;
    // 2 * (t - t^2/2 - 0.5)
    return t + t - t * t - 1;
  }
  // -1 < t < 0
  if (t > 1 - dt) {
    t = (t - 1) / dt;
    // 2 * (t^2/2 + t + 0.5)
    return t * t + t + t + 1;
  }
  // 0 otherwise
  return 0;
}

export class SawOsc {
  constructor(props = {}) {
    this.phase = props.phase ?? 0;
  }
  update(freq) {
    const dt = freq / SAMPLE_RATE;
    let p = polyBlep(this.phase, dt);
    let s = 2 * this.phase - 1 - p;
    this.phase += dt;
    if (this.phase > 1) {
      this.phase -= 1;
    }
    return s;
  }
}

function getUnisonDetune(unison, detune, voiceIndex) {
  if (unison < 2) {
    return 0;
  }
  const lerp = (a, b, n) => {
    return n * (b - a) + a;
  };
  return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
}
function applySemitoneDetuneToFrequency(frequency, detune) {
  return frequency * Math.pow(2, detune / 12);
}
export class SupersawOsc {
  constructor(props = {}) {
    //TODO: figure out a good way to pass in these params
    this.voices = props.voices ?? 5;
    this.freqspread = props.freqspread ?? 0.2;
    this.panspread = props.panspread ?? 0.4;
    this.phase = new Float32Array(this.voices).map(() => Math.random());
  }
  update(freq) {
    const gain1 = Math.sqrt(1 - this.panspread);
    const gain2 = Math.sqrt(this.panspread);
    let sl = 0;
    let sr = 0;
    for (let n = 0; n < this.voices; n++) {
      const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n));
      const dt = freqAdjusted / SAMPLE_RATE;
      const isOdd = (n & 1) == 1;
      let gainL = gain1;
      let gainR = gain2;
      // invert right and left gain
      if (isOdd) {
        gainL = gain2;
        gainR = gain1;
      }
      let p = polyBlep(this.phase[n], dt);
      let s = 2 * this.phase[n] - 1 - p;
      sl = sl + s * gainL;
      sr = sr + s * gainL;

      this.phase[n] += dt;
      if (this.phase[n] > 1) {
        this.phase[n] -= 1;
      }
    }

    return sl + sr;
    //TODO: make stereo
    // return [sl, sr];
  }
}

export class TriOsc {
  phase = 0;
  update(freq) {
    this.phase += ISR * freq;
    let phase = this.phase % 1;
    let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5);
    return value * 2 - 1;
  }
}

export class TwoPoleFilter {
  s0 = 0;
  s1 = 0;
  update(s, cutoff, resonance = 0) {
    // Out of bound values can produce NaNs
    resonance = Math.max(resonance, 0);

    cutoff = Math.min(cutoff, 20000);
    let c = 2 * Math.sin(cutoff * PI_DIV_SR);
    c = clamp(c, 0, 1.14); // this line prevents instability TODO: test

    const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
    const mrc = 1 - r * c;

    this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
    this.s1 = mrc * this.s1 + c * this.s0; // lpf
    return this.s1; // return lpf by default
  }
}

class PulseOsc {
  constructor(phase = 0) {
    this.phase = phase;
  }
  saw(offset, dt) {
    let phase = (this.phase + offset) % 1;
    let p = polyBlep(phase, dt);
    return 2 * phase - 1 - p;
  }
  update(freq, pw = 0.5) {
    const dt = freq / SAMPLE_RATE;
    let pulse = this.saw(0, dt) - this.saw(pw, dt);
    this.phase = (this.phase + dt) % 1;
    return pulse + pw * 2 - 1;
  }
}

// non bandlimited (has aliasing)
export class PulzeOsc {
  phase = 0;
  update(freq, duty = 0.5) {
    this.phase += ISR * freq;
    let cyclePos = this.phase % 1;
    return cyclePos < duty ? 1 : -1;
  }
}

export class Dust {
  update = (density) => (Math.random() < density * ISR ? Math.random() : 0);
}

export class WhiteNoise {
  update() {
    return Math.random() * 2 - 1;
  }
}

export class BrownNoise {
  constructor() {
    this.out = 0;
  }
  update() {
    let white = Math.random() * 2 - 1;
    this.out = (this.out + 0.02 * white) / 1.02;
    return this.out;
  }
}

export class PinkNoise {
  constructor() {
    this.b0 = 0;
    this.b1 = 0;
    this.b2 = 0;
    this.b3 = 0;
    this.b4 = 0;
    this.b5 = 0;
    this.b6 = 0;
  }

  update() {
    const white = Math.random() * 2 - 1;

    this.b0 = 0.99886 * this.b0 + white * 0.0555179;
    this.b1 = 0.99332 * this.b1 + white * 0.0750759;
    this.b2 = 0.969 * this.b2 + white * 0.153852;
    this.b3 = 0.8665 * this.b3 + white * 0.3104856;
    this.b4 = 0.55 * this.b4 + white * 0.5329522;
    this.b5 = -0.7616 * this.b5 - white * 0.016898;

    const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
    this.b6 = white * 0.115926;

    return pink * 0.11;
  }
}

export class Impulse {
  phase = 1;
  update(freq) {
    this.phase += ISR * freq;
    let v = this.phase >= 1 ? 1 : 0;
    this.phase = this.phase % 1;
    return v;
  }
}

export class ClockDiv {
  inSgn = true;
  outSgn = true;
  clockCnt = 0;
  update(clock, factor) {
    let curSgn = clock > 0;
    if (this.inSgn != curSgn) {
      this.clockCnt++;
      if (this.clockCnt >= factor) {
        this.clockCnt = 0;
        this.outSgn = !this.outSgn;
      }
    }

    this.inSgn = curSgn;
    return this.outSgn ? 1 : -1;
  }
}

export class Hold {
  value = 0;
  trigSgn = false;
  update(input, trig) {
    if (!this.trigSgn && trig > 0) this.value = input;
    this.trigSgn = trig > 0;
    return this.value;
  }
}

function lerp(x, y0, y1, exponent = 1) {
  if (x <= 0) return y0;
  if (x >= 1) return y1;

  let curvedX;

  if (exponent === 0) {
    curvedX = x; // linear
  } else if (exponent > 0) {
    curvedX = Math.pow(x, exponent); // ease-in
  } else {
    curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out
  }

  return y0 + (y1 - y0) * curvedX;
}

export class ADSR {
  constructor(props = {}) {
    this.state = 'off';
    this.startTime = 0;
    this.startVal = 0;
    this.decayCurve = props.decayCurve ?? 1;
  }

  update(curTime, gate, attack, decay, susVal, release) {
    switch (this.state) {
      case 'off': {
        if (gate > 0) {
          this.state = 'attack';
          this.startTime = curTime;
          this.startVal = 0;
        }
        return 0;
      }
      case 'attack': {
        let time = curTime - this.startTime;
        if (time > attack) {
          this.state = 'decay';
          this.startTime = curTime;
          return 1;
        }
        return lerp(time / attack, this.startVal, 1, 1);
      }
      case 'decay': {
        let time = curTime - this.startTime;
        let curVal = lerp(time / decay, 1, susVal, -this.decayCurve);
        if (gate <= 0) {
          this.state = 'release';
          this.startTime = curTime;
          this.startVal = curVal;
          return curVal;
        }
        if (time > decay) {
          this.state = 'sustain';
          this.startTime = curTime;
          return susVal;
        }
        return curVal;
      }
      case 'sustain': {
        if (gate <= 0) {
          this.state = 'release';
          this.startTime = curTime;
          this.startVal = susVal;
        }
        return susVal;
      }
      case 'release': {
        let time = curTime - this.startTime;

        if (time > release) {
          this.state = 'off';
          return 0;
        }
        let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve);
        if (gate > 0) {
          this.state = 'attack';
          this.startTime = curTime;
          this.startVal = curVal;
        }
        return curVal;
      }
    }
    throw 'invalid envelope state';
  }
}

/*
        impulse(1).ad(.1).mul(sine(200))
.add(x=>x.delay(.1).mul(.8))
.out()*/
const MAX_DELAY_TIME = 10;
export class PitchDelay {
  lpf = new TwoPoleFilter();
  constructor(_props = {}) {
    this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE);
    this.writeIdx = 0;
    this.readIdx = 0;
    this.numSamples = 0;
  }
  write(s, delayTime) {
    // Calculate how far in the past to read
    this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
    this.writeIdx = (this.writeIdx + 1) % this.numSamples;
    this.buffer[this.writeIdx] = s;
    this.readIdx = this.writeIdx - this.numSamples + 1;

    // If past the start of the buffer, wrap around (Q: is this possible?)
    if (this.readIdx < 0) this.readIdx += this.numSamples;
  }
  update(input, delayTime, speed = 1) {
    this.write(input, delayTime);
    let index = this.readIdx;
    if (speed < 0) {
      index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples);
    } else {
      index = Math.floor(this.readIdx * speed) % this.numSamples;
    }
    const s = this.lpf.update(this.buffer[index], 0.9, 0);

    return s;
  }
}

export class Delay {
  writeIdx = 0;
  readIdx = 0;
  buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0)
  write(s, delayTime) {
    this.writeIdx = (this.writeIdx + 1) % this.buffer.length;
    this.buffer[this.writeIdx] = s;
    // Calculate how far in the past to read
    let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
    this.readIdx = this.writeIdx - numSamples;
    // If past the start of the buffer, wrap around
    if (this.readIdx < 0) this.readIdx += this.buffer.length;
  }
  update(input, delayTime) {
    this.write(input, delayTime);
    return this.buffer[this.readIdx];
  }
}
//TODO: Figure out why clicking at the start off the buffer
export class Chorus {
  delay = new Delay();
  modulator = new TriOsc();
  update(input, mix, delayTime, modulationFreq, modulationDepth) {
    const m = this.modulator.update(modulationFreq) * modulationDepth;
    const c = this.delay.update(input, delayTime * (1 + m));
    return crossfade(input, c, mix);
  }
}

export class Fold {
  update(input = 0, rate = 0) {
    if (rate < 0) rate = 0;
    rate = rate + 1;
    input = input * rate;
    return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25);
  }
}

export class Lag {
  lagUnit = 4410;
  s = 0;
  update(input, rate) {
    // Remap so the useful range is around [0, 1]
    rate = rate * this.lagUnit;
    if (rate < 1) rate = 1;
    this.s += (1 / rate) * (input - this.s);
    return this.s;
  }
}

export class Slew {
  last = 0;
  update(input, up, dn) {
    const upStep = up * ISR;
    const downStep = dn * ISR;
    let delta = input - this.last;
    if (delta > upStep) {
      delta = upStep;
    } else if (delta < -downStep) {
      delta = -downStep;
    }
    this.last += delta;
    return this.last;
  }
}

// overdrive style distortion (adapted from noisecraft) currently unused
export function applyDistortion(x, amount) {
  amount = Math.min(Math.max(amount, 0), 1);
  amount -= 0.01;
  var k = (2 * amount) / (1 - amount);
  var y = ((1 + k) * x) / (1 + k * Math.abs(x));
  return y;
}

export class Sequence {
  clockSgn = true;
  step = 0;
  first = true;
  update(clock, ...ins) {
    if (!this.clockSgn && clock > 0) {
      this.step = (this.step + 1) % ins.length;
      this.clockSgn = clock > 0;
      return 0; // set first sample to zero to retrigger gates on step change...
    }
    this.clockSgn = clock > 0;
    return ins[this.step];
  }
}

// sample rate bit crusher
export class Coarse {
  hold = 0;
  t = 0;
  update(input, coarse) {
    if (this.t++ % coarse === 0) {
      this.t = 0;
      this.hold = input;
    }
    return this.hold;
  }
}

// amplitude bit crusher
export class Crush {
  update(input, crush) {
    crush = Math.max(1, crush);
    const x = Math.pow(2, crush - 1);
    return Math.round(input * x) / x;
  }
}

// this is the distort from superdough
export class Distort {
  update(input, distort = 0, postgain = 1) {
    postgain = Math.max(0.001, Math.min(1, postgain));
    const shape = Math.expm1(distort);
    return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain;
  }
}
// distortion could be expressed as a function, because it's stateless

export class BufferPlayer {
  static samples = new Map(); // string -> { channels, sampleRate }
  buffer; // Float32Array
  sampleRate;
  pos = 0;
  sampleFreq = note2freq();
  constructor(buffer, sampleRate, normalize) {
    this.buffer = buffer;
    this.sampleRate = sampleRate;
    this.duration = this.buffer.length / this.sampleRate;
    this.speed = SAMPLE_RATE / this.sampleRate;
    if (normalize) {
      // this will make the buffer last 1s if freq = sampleFreq
      // it's useful to loop samples (e.g. fit function)
      this.speed *= this.duration;
    }
  }
  update(freq) {
    if (this.pos >= this.buffer.length) {
      return 0;
    }
    const speed = (freq / this.sampleFreq) * this.speed;
    let s = this.buffer[Math.floor(this.pos)];
    this.pos = this.pos + speed;
    return s;
  }
}

export function _rangex(sig, min, max) {
  let logmin = Math.log(min);
  let range = Math.log(max) - logmin;
  const unipolar = (sig + 1) / 2;
  return Math.exp(unipolar * range + logmin);
}

// duplicate
export const getADSR = (params, curve = 'linear', defaultValues) => {
  const envmin = curve === 'exponential' ? 0.001 : 0.001;
  const releaseMin = 0.01;
  const envmax = 1;
  const [a, d, s, r] = params;
  if (a == null && d == null && s == null && r == null) {
    return defaultValues ?? [envmin, envmin, envmax, releaseMin];
  }
  const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
  return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
};

let shapes = {
  sine: SineOsc,
  saw: SawOsc,
  zaw: ZawOsc,
  sawtooth: SawOsc,
  zawtooth: ZawOsc,
  supersaw: SupersawOsc,
  tri: TriOsc,
  triangle: TriOsc,
  pulse: PulseOsc,
  square: PulseOsc,
  pulze: PulzeOsc,
  dust: Dust,
  crackle: Dust,
  impulse: Impulse,
  white: WhiteNoise,
  brown: BrownNoise,
  pink: PinkNoise,
};

const defaultDefaultValues = {
  chorus: 0,
  note: 48,
  s: 'triangle',
  bank: '',
  gain: 1,
  postgain: 1,
  velocity: 1,
  density: '.03',
  ftype: '12db',
  fanchor: 0,
  //resonance: 1, // superdough resonance is scaled differently
  resonance: 0,
  //hresonance: 1, // superdough resonance is scaled differently
  hresonance: 0,
  // bandq: 1,  // superdough resonance is scaled differently
  bandq: 0,
  channels: [1, 2],
  phaserdepth: 0.75,
  shapevol: 1,
  distortvol: 1,
  delay: 0,
  byteBeatExpression: '0',
  delayfeedback: 0.5,
  delayspeed: 1,
  delaytime: 0.25,
  orbit: 1,
  i: 1,
  fft: 8,
  z: 'triangle',
  pan: 0.5,
  fmh: 1,
  fmenv: 0, // differs from superdough
  speed: 1,
  pw: 0.5,
};

let getDefaultValue = (key) => defaultDefaultValues[key];

const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 };
const note2midi = (note, defaultOctave = 3) => {
  let [pc, acc = '', oct = ''] =
    String(note)
      .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)
      ?.slice(1) || [];
  if (!pc) {
    throw new Error('not a note: "' + note + '"');
  }
  const chroma = chromas[pc.toLowerCase()];
  const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
  const octave = Number(oct || defaultOctave);
  return (octave + 1) * 12 + chroma + offset;
};
const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440;
const note2freq = (note) => {
  note = note || getDefaultValue('note');
  if (typeof note === 'string') {
    note = note2midi(note, 3); // e.g. c3 => 48
  }
  return midi2freq(note);
};

export class DoughVoice {
  /** @type {number} */
  id = 0;
  /** @type {number[]} */
  out = [0, 0];

  /** @type {number | undefined} */
  attack;
  /** @type {number | undefined} */
  decay;
  /** @type {number | undefined} */
  sustain;
  /** @type {number} */
  release;
  /** @type {number} */
  _begin;
  /** @type {number} */
  _duration;

  /** @type {any} */
  _sound;
  /** @type {number} */
  _channels = 1;
  /** @type {BufferPlayer[] | undefined} */
  _buffers;
  /** @type {string | undefined} */
  unit;

  /** @type {ADSR | undefined} */
  _penv;
  /** @type {number | undefined} */
  penv;
  /** @type {number | undefined} */
  pattack;
  /** @type {number | undefined} */
  pdecay;
  /** @type {number | undefined} */
  psustain;
  /** @type {number | undefined} */
  prelease;

  /** @type {number | undefined} */
  vib;

  _vib;
  /** @type {number | undefined} */
  vibmod;

  /** @type {SineOsc | undefined} */
  _fm;
  /** @type {number | undefined} */
  fmh;
  /** @type {number | undefined} */
  fmi;

  /** @type {ADSR | undefined} */
  _fmenv;
  /** @type {number | undefined} */
  fmattack;
  /** @type {number | undefined} */
  fmdecay;
  /** @type {number | undefined} */
  fmsustain;
  /** @type {number | undefined} */
  fmrelease;

  /** @type {ADSR | undefined} */
  _lpenv;
  lpenv;
  /** @type {number | undefined} */
  lpattack;
  /** @type {number | undefined} */
  lpdecay;
  /** @type {number | undefined} */
  lpsustain;
  /** @type {number | undefined} */
  lprelease;

  /** @type {ADSR | undefined} */
  _hpenv;
  /** @type {number | undefined} */
  hpenv;
  /** @type {number | undefined} */
  hpattack;
  /** @type {number | undefined} */
  hpdecay;
  /** @type {number | undefined} */
  hpsustain;
  /** @type {number | undefined} */
  hprelease;

  /** @type {ADSR | undefined} */
  _bpenv;
  /** @type {number | undefined} */
  bpenv;
  /** @type {number | undefined} */
  bpattack;
  /** @type {number | undefined} */
  bpdecay;
  /** @type {number | undefined} */
  bpsustain;
  /** @type {number | undefined} */
  bprelease;

  /** @type {number | undefined} */
  cutoff;
  /** @type {number | undefined} */
  hcutoff;
  /** @type {number | undefined} */
  bandf;
  /** @type {number | undefined} */
  coarse;
  /** @type {number | undefined} */
  crush;
  /** @type {number | undefined} */
  distort;

  /** @type {number} */
  freq;
  /** @type {string | undefined} */
  note;

  /** @type {TwoPoleFilter[] | null | undefined} */
  _lpf;
  /** @type {TwoPoleFilter[] | null | undefined} */
  _hpf;
  /** @type {TwoPoleFilter[] | null | undefined} */
  _bpf;
  /** @type {Chorus[] | null | undefined} */
  _chorus;
  /** @type {Coarse[] | null | undefined} */
  _coarse;
  /** @type {Crush[] | null | undefined} */
  _crush;
  /** @type {Distort[] | null | undefined} */
  _distort;

  /**
   * @param {DoughVoice} value
   */
  constructor(value) {
    // mandatory controls
    this.freq ??= note2freq(value.note);
    this._begin = value._begin;
    this._duration = value._duration;
    this.release = value.release ?? 0;
    // the rest.. we use $ for readability
    let $ = this;
    Object.assign($, value);
    $.s = $.s ?? getDefaultValue('s');
    $.gain = applyGainCurve($.gain ?? getDefaultValue('gain'));
    $.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity'));
    $.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain'));
    $.density = $.density ?? getDefaultValue('density');
    $.fanchor = $.fanchor ?? getDefaultValue('fanchor');
    $.drive = $.drive ?? 0.69;
    $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth');
    $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol'));
    $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol'));
    $.i = $.i ?? getDefaultValue('i');
    $.chorus = $.chorus ?? getDefaultValue('chorus');
    $.fft = $.fft ?? getDefaultValue('fft');
    $.pan = $.pan ?? getDefaultValue('pan');
    $.orbit = $.orbit ?? getDefaultValue('orbit');
    $.fmenv = $.fmenv ?? getDefaultValue('fmenv');
    $.resonance = $.resonance ?? getDefaultValue('resonance');
    $.hresonance = $.hresonance ?? getDefaultValue('hresonance');
    $.bandq = $.bandq ?? getDefaultValue('bandq');
    $.speed = $.speed ?? getDefaultValue('speed');
    $.pw = $.pw ?? getDefaultValue('pw');

    [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]);

    $._holdEnd = $._begin + $._duration; // needed for gate
    $._end = $._holdEnd + $.release + 0.01; // needed for despawn

    if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) {
      $.s = 'zaw'; // polyblepped saw when fm is applied
    }

    if (shapes[$.s]) {
      const SourceClass = shapes[$.s];
      $._sound = new SourceClass();
      $._channels = 1;
    } else if (BufferPlayer.samples.has($.s)) {
      const sample = BufferPlayer.samples.get($.s);
      $._buffers = [];
      $._channels = sample.channels.length;
      for (let i = 0; i < $._channels; i++) {
        $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c'
      }
    } else {
      console.warn('sound not loaded', $.s);
    }

    if ($.penv) {
      $._penv = new ADSR({ decayCurve: 4 });
      [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]);
    }

    if ($.vib) {
      $._vib = new SineOsc();
      $.vibmod = $.vibmod ?? getDefaultValue('vibmod');
    }

    if ($.fmi) {
      $._fm = new SineOsc();
      $.fmh = $.fmh ?? getDefaultValue('fmh');
      if ($.fmenv) {
        $._fmenv = new ADSR({ decayCurve: 2 });
        [$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]);
      }
    }

    // gain envelope
    $._adsr = new ADSR({ decayCurve: 2 });
    // delay
    $.delay = applyGainCurve($.delay ?? getDefaultValue('delay'));
    $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback');
    $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed');
    $.delaytime = $.delaytime ?? getDefaultValue('delaytime');

    // filter setup
    if ($.lpenv) {
      $._lpenv = new ADSR({ decayCurve: 4 });
      [$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]);
    }
    if ($.hpenv) {
      $._hpenv = new ADSR({ decayCurve: 4 });
      [$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]);
    }
    if ($.bpenv) {
      $._bpenv = new ADSR({ decayCurve: 4 });
      [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]);
    }

    // channelwise effects setup
    $._chorus = $.chorus ? [] : null;
    $._lpf = $.cutoff ? [] : null;
    $._hpf = $.hcutoff ? [] : null;
    $._bpf = $.bandf ? [] : null;
    $._coarse = $.coarse ? [] : null;
    $._crush = $.crush ? [] : null;
    $._distort = $.distort ? [] : null;
    for (let i = 0; i < this._channels; i++) {
      $._lpf?.push(new TwoPoleFilter());
      $._hpf?.push(new TwoPoleFilter());
      $._bpf?.push(new TwoPoleFilter());
      $._chorus?.push(new Chorus());
      $._coarse?.push(new Coarse());
      $._crush?.push(new Crush());
      $._distort?.push(new Distort());
    }
  }
  update(t) {
    if (!this._sound && !this._buffers) {
      return 0;
    }
    let gate = Number(t >= this._begin && t <= this._holdEnd);

    let freq = this.freq * this.speed;

    // frequency modulation
    if (this._fm && this.fmh !== undefined && this.fmi !== undefined) {
      let fmi = this.fmi;
      if (this._fmenv) {
        const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease);
        fmi = this.fmenv * env * fmi;
      }
      const modfreq = freq * this.fmh;
      const modgain = modfreq * fmi;
      freq = freq + this._fm.update(modfreq) * modgain;
    }

    // vibrato
    if (this._vib && this.vibmod !== undefined) {
      freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12);
    }

    // pitch envelope
    if (this._penv && this.penv !== undefined) {
      const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease);
      freq = freq + env * this.penv;
    }

    // filters
    let lpf = this.cutoff;
    if (lpf !== undefined && this._lpenv) {
      const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease);
      lpf = this.lpenv * env * lpf + lpf;
    }
    let hpf = this.hcutoff;
    if (hpf !== undefined && this._hpenv && this.hpenv !== undefined) {
      const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease);
      hpf = 2 ** this.hpenv * env * hpf + hpf;
    }
    let bpf = this.bandf;
    if (bpf !== undefined && this._bpenv && this.bpenv !== undefined) {
      const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease);
      bpf = 2 ** this.bpenv * env * bpf + bpf;
    }
    // gain envelope
    const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release);

    // channelwise dsp
    for (let i = 0; i < this._channels; i++) {
      // sound source
      if (this._sound && this.s === 'pulse') {
        this.out[i] = this._sound.update(freq, this.pw);
      } else if (this._sound) {
        this.out[i] = this._sound.update(freq);
      } else if (this._buffers) {
        this.out[i] = this._buffers[i].update(freq);
      }
      this.out[i] = this.out[i] * this.gain * this.velocity;
      if (this._chorus) {
        const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11);
        this.out[i] = c + this.out[i];
      }

      if (this._lpf) {
        this._lpf[i].update(this.out[i], lpf, this.resonance);
        this.out[i] = this._lpf[i].s1;
      }
      if (this._hpf) {
        this._hpf[i].update(this.out[i], hpf, this.hresonance);
        this.out[i] = this.out[i] - this._hpf[i].s1;
      }
      if (this._bpf) {
        this._bpf[i].update(this.out[i], bpf, this.bandq);
        this.out[i] = this._bpf[i].s0;
      }
      if (this._coarse) {
        this.out[i] = this._coarse[i].update(this.out[i], this.coarse);
      }
      if (this._crush) {
        this.out[i] = this._crush[i].update(this.out[i], this.crush);
      }
      if (this._distort) {
        this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol);
      }
      this.out[i] = this.out[i] * env;
      this.out[i] = this.out[i] * this.postgain;
      if (!this._buffers) {
        this.out[i] = this.out[i] * 0.2; // turn down waveform
      }
    }
    if (this._channels === 1) {
      this.out[1] = this.out[0];
    }
    if (this.pan !== 0.5) {
      const panpos = (this.pan * Math.PI) / 2;
      this.out[0] = this.out[0] * Math.cos(panpos);
      this.out[1] = this.out[1] * Math.sin(panpos);
    }
  }
}

// this class is the interface to the "outer world"
// it handles spawning and despawning of DoughVoice's
export class Dough {
  voices = []; // DoughVoice[]
  vid = 0;
  q = [];
  out = [0, 0];
  delaysend = [0, 0];
  delaytime = getDefaultValue('delaytime');
  delayfeedback = getDefaultValue('delayfeedback');
  delayspeed = getDefaultValue('delayspeed');
  t = 0;
  // sampleRate: number, currentTime: number (seconds)
  constructor(sampleRate = 48000, currentTime = 0) {
    this.sampleRate = sampleRate;
    this.t = Math.floor(currentTime * sampleRate); // samples
    // console.log('init dough', this.sampleRate, this.t);
    this._delayL = new Delay();
    this._delayR = new Delay();
  }
  loadSample(name, channels, sampleRate) {
    BufferPlayer.samples.set(name, { channels, sampleRate });
  }
  scheduleSpawn(value) {
    if (value._begin === undefined) {
      throw new Error('[dough]: scheduleSpawn expected _begin to be set');
    }
    if (value._duration === undefined) {
      throw new Error('[dough]: scheduleSpawn expected _duration to be set');
    }
    value.sampleRate = this.sampleRate;
    // convert seconds to samples
    const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs
    this.schedule({ time, type: 'spawn', arg: value });
  }
  spawn(value) {
    value.id = this.vid++;
    const voice = new DoughVoice(value);
    this.voices.push(voice);
    // console.log('spawn', voice.id, 'voices:', this.voices.length);
    // schedule removal
    const endTime = Math.ceil(voice._end * this.sampleRate);
    this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id });
  }
  despawn(vid) {
    this.voices = this.voices.filter((v) => v.id !== vid);
    // console.log('despawn', vid, 'voices:', this.voices.length);
  }
  // schedules a function call with a single argument
  // msg = {time:number,type:string, arg: any}
  // the Dough method "type" will be called with "arg" at "time"
  schedule(msg) {
    if (!this.q.length) {
      // if empty, just push
      this.q.push(msg);
      return;
    }
    // not empty
    // find index where msg.time fits in
    let i = 0;
    while (i < this.q.length && this.q[i].time < msg.time) {
      i++;
    }
    // this ensures q stays sorted by time, so we only need to check q[0]
    this.q.splice(i, 0, msg);
  }
  // maybe update should be called once per block instead for perf reasons?
  update() {
    // go over q
    while (this.q.length > 0 && this.q[0].time <= this.t) {
      // console.log('schedule', this.q[0]);
      // trigger due messages. q is sorted, so we only need to check q[0]
      this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method
      this.q.shift();
    }
    // add active voices
    this.out[0] = 0;
    this.out[1] = 0;
    for (let v = 0; v < this.voices.length; v++) {
      this.voices[v].update(this.t / this.sampleRate);
      this.out[0] += this.voices[v].out[0];
      this.out[1] += this.voices[v].out[1];
      if (this.voices[v].delay) {
        this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay;
        this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay;
        this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice
        this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice
        this.delayfeedback = this.voices[v].delayfeedback;
      }
    }
    // todo: how to change delaytime / delayfeedback from a voice?
    const delayL = this._delayL.update(this.delaysend[0], this.delaytime);
    const delayR = this._delayR.update(this.delaysend[1], this.delaytime);

    this.delaysend[0] = delayL * this.delayfeedback;
    this.delaysend[1] = delayR * this.delayfeedback;
    this.out[0] += delayL;
    this.out[1] += delayR;
    this.t++;
  }
}
