Dimmadome's DimmasitePostsProjects

Synthetic Cello

Some time ago I came across this video by 9b0. In there, they create a pretty realistic (in my opinion) cello sound by taking a saw wave, and then filtering it in a fancy way.

They first take a saw wave oscillator, and add legato to it (I haven't done this part). Then they pass it through a simple attack/decay envelope, and add some vibrato, as that adds a bit to the sound. They do modulate the vibrato by another envelope, which I haven't done either. They also add some noise to it.

As most instruments like a cello also lose some energy in the strings and body, they add a bandpass filter to the saw wave.

Magic Filter

But it still doesn't sound like a cello! The trick is supposedly rather simple: pass the output to a 100% wet reverb, with a very small size.

Yeah, that's it.

Funny

— 9b0

Let's recreate it in dittytoy!

The original reverb they used is a combination of delays and allpass filters, and unfortunately I don't have the code to recreate it. Luckily, Geraint Luff has us covered, with a simple and foolproof reverb.

Now, doing the above, and putting it into this reverb results in the following (also available on dittytoy):

Cello with reverb – Paused
Visualizer
Code (click to expand)
// inspired by https://www.youtube.com/watch?v=Aktb_dmY4vk
// essentially, pass a bandpassed saw wave with vibrato and a bit of noise into a short reverb

// bit slower, original is 210
ditty.bpm = 140;

input.tuning = 0.5; //min=0.125, max=2, step=0.125

input.vibrato_amount = 3.5; //min=0, max=5, step=0.01
input.vibrato_freq = 32; //min=0, max=60, step=0.01

input.loss_freq = 500; //min=100, max=8000, step=1
input.loss_q = 1.3;//min=0.1, max=10, step=0.1
input.loss_wet = 1; //min=0, max=1, step=0.01

input.body_length = 0.6; //min=0, max=1, step=0.01
input.body_diffuse = 0.8; //min=0, max=1, step=0.01
input.body_feedback = 0.6; //min=0, max=1, step=0.01
input.body_damping = 5200; //min=10, max=10000, step=1
input.body_wet = 1.0; //min=0, max=1, step=0.01

function softclip(x) {
  return x < -1 ? -1 : x > 1 ? 1 : 1.5 * (1 - x * x / 3) * x;
}

function varsaw(p, formant) {
  let x = p - ~~p;
  return (x - 0.5) * softclip(formant * x * (1 - x)) * 2;
}

const osc = synth.def(
  class {
    constructor(options) {
      // The value of the note argument of the play call is retrievable via options.note.
      this.phase = Math.random();
      this.vibrato = Math.random();

      // filter state
      this.ic1eq = 0.0;
      this.ic2eq = 0.0;
    }
    process(note, env, tick, options) {
      // saw wave
      this.vibrato += ditty.dt * input.vibrato_freq;
      this.phase += (midi_to_hz(note) + Math.sin(this.vibrato) * input.vibrato_amount)
        * ditty.dt * input.tuning;

      const saw = varsaw(this.phase, 50);
      const v0 = saw * env.value;

      // SVF filter
      // https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf
      const g = Math.tan(Math.PI * input.loss_freq * ditty.dt);
      const k = 1 / input.loss_q;
      const a1 = 1 / (1 + g * (g + k));
      const a2 = g * a1;
      const a3 = g * a2;

      // tick
      const v3 = v0 - this.ic2eq;
      const v1 = a1 * this.ic1eq + a2 * v3;
      const v2 = this.ic2eq + a2 * this.ic1eq + a3 * v3;

      // update
      this.ic1eq = 2 * v1 - this.ic1eq;
      this.ic2eq = 2 * v2 - this.ic2eq;

      // bandpass out
      return v1 * input.loss_wet + (1 - input.loss_wet) * v0;
    }
  }, {
  // attack parameters
  attack: 0.05,
  decay: 0.05,
  sustain: 0.7,
  release: 0.1,
  env: adsr,
}
);


// from struss
class Delayline {
  constructor(n) {
    this.n = ~~n;
    this.p = 0;
    this.lastOut = 0;
    this.data = new Float32Array(n);
  }
  clock(input) {
    this.lastOut = this.data[this.p];
    this.data[this.p] = input;
    if (++this.p >= this.n) this.p = 0;
  }
  tap(offset) {
    let x = this.p - (offset | 0) - 1;
    x %= this.n;
    if (x < 0) x += this.n;
    return this.data[x];
  }
}

function newdelay(count) {
  return new Array(count)
    .fill(null)
    .map(_ => new Delayline(ditty.sampleRate * 0.01))
}

function newlens(count, max) {
  return new Array(count)
    .fill(1)
    .map(_ => Math.random() * max * 0.01);
}

function hada8([c0, c1, c2, c3, c4, c5, c6, c7]) {
  // shuffle
  [c0, c1, c2, c3, c4, c5, c6, c7] = [c3, -c7, c1, -c4, -c5, c6, c0, c2];

  // 8x8 hadamard matrix
  return [
    (c0 + c1 + c2 + c3 + c4 + c5 + c6 + c7) * Math.sqrt(1 / 8),
    (c0 - c1 + c2 - c3 + c4 - c5 + c6 - c7) * Math.sqrt(1 / 8),
    (c0 + c1 - c2 - c3 + c4 + c5 - c6 - c7) * Math.sqrt(1 / 8),
    (c0 - c1 - c2 + c3 + c4 - c5 - c6 + c7) * Math.sqrt(1 / 8),
    (c0 + c1 + c2 + c3 - c4 - c5 - c6 - c7) * Math.sqrt(1 / 8),
    (c0 - c1 + c2 - c3 - c4 + c5 - c6 + c7) * Math.sqrt(1 / 8),
    (c0 + c1 - c2 - c3 - c4 - c5 + c6 + c7) * Math.sqrt(1 / 8),
    (c0 - c1 - c2 + c3 - c4 + c5 + c6 - c7) * Math.sqrt(1 / 8),
  ];
}

function house8([c0, c1, c2, c3, c4, c5, c6, c7]) {
  const sum = c0 + c1 + c2 + c3 + c4 + c5 + c6 + c7;
  return [c0, c1, c2, c3, c4, c5, c6, c7].map(x => x - sum * 0.25);
}

// geraint luff reverberator
// https://signalsmith-audio.co.uk/writing/2021/lets-write-a-reverb/
class Reverb {
  constructor(n) {
    // diffusors
    this.diff1 = newdelay(8);
    this.diff2 = newdelay(8);
    this.diff3 = newdelay(8);
    this.diff4 = newdelay(8);

    // length of diffusors
    this.lens1 = newlens(8, ditty.sampleRate);
    this.lens2 = newlens(8, ditty.sampleRate);
    this.lens3 = newlens(8, ditty.sampleRate);
    this.lens4 = newlens(8, ditty.sampleRate);

    // reverb loop
    this.reverb = newdelay(8);
    this.rvlens = newlens(8, ditty.sampleRate);
    this.rvlp = new Array(8).fill(0);
  }
  tick(inp) {
    // split into channels
    const channels = new Array(8).fill(inp);

    // diffuser 1
    const diff1 = hada8(this.diff1.map((d, i) => {
      d.clock(channels[i]);
      return d.tap(this.lens1[i] * input.body_diffuse);
    }));

    // diffuser 2
    const diff2 = hada8(this.diff2.map((d, i) => {
      d.clock(diff1[i]);
      return d.tap(this.lens2[i] * input.body_diffuse);
    }));

    // diffuser 3
    const diff3 = hada8(this.diff3.map((d, i) => {
      d.clock(diff2[i]);
      return d.tap(this.lens3[i] * input.body_diffuse);
    }));

    // diffuser 4
    const diff4 = hada8(this.diff4.map((d, i) => {
      d.clock(diff3[i]);
      return d.tap(this.lens4[i] * input.body_diffuse);
    }));

    // reverb loop
    const fb = house8(this.reverb.map((d, i) => {
      const tap = d.tap(this.rvlens[i] * input.body_length);
      return tap * input.body_feedback + diff4[i];
    }));

    // filter
    const damped = fb.map((x, i) => {
      // one pole
      this.rvlp[i]
        += (x - this.rvlp[i])
        * (1 - Math.exp(-input.body_damping * ditty.dt * Math.PI * 2));

      return this.rvlp[i];
    });

    // feedback
    this.reverb.forEach((d, i) => d.clock(damped[i]));

    // and out again
    return fb.reduce((a, d) => a + d, 0) * Math.sqrt(1 / channels.length);
  }
}

// Simple allpass reverberator, based on this article:
// http://www.spinsemi.com/knowledge_base/effects.html
const reverb = filter.def(class {
  constructor(options) {
    this.l = new Reverb(1);
    this.r = new Reverb(1);
  }
  process(inp, options) {
    const [l, r] = inp;
    return [
      this.l.tick(l) * input.body_wet
      + l * (1 - input.body_wet),
      this.r.tick(r) * input.body_wet
      + r * (1 - input.body_wet)
    ];
  }
});

// === original ===
// Forked from "Wizards & Warriors (main menu)" by romaindurand
// https://dittytoy.net/ditty/3066954356

function melodyPattern(notes, baseNote) {
  return () => {
    for (i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
      osc.play(baseNote, { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

function simpleMelodyPattern(notes) {
  // calculate length for it to sound nice
  let lens = new Array(notes.length).fill(0.45);
  let last = 0;

  // every off note adds to the on length of the last on note
  for (let i = 0; i < lens.length; i++) {
    if (notes[i] === 0) lens[last] += 0.5;
    else last = i;
  }

  return () => {
    for (let i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: lens[i], pan: -0.2 - Math.random() * 0.1 });
      osc.play(notes[i] + 12, { duration: lens[i], pan: -0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

const melodySeq0 = () => {
  melodyPattern([d5, e5, f5, g5], a4)();
  melodyPattern([bb4, d5, g5, f5], g4)();
  melodyPattern([e5, f5], c5)();
  melodyPattern([g5, c5], g4)();
  melodyPattern([bb4, a4, f5, e5], f4)();

  const notes0 = [d5, e5, f5, d5];
  melodyPattern(notes0, bb4)();
  //same as previous pattern with another base note
  melodyPattern(notes0, g4)();

  melodyPattern([e5, cs5, e5, cs5], a4)();
  melodyPattern([a5, cs5, a5, a5], a4)();
};

const melodySeq1 = () => {
  melodyPattern([f5, d5], a4)();
  simpleMelodyPattern([f5, g5, a5, a4])();
  simpleMelodyPattern([
    a5, bb4, d5, bb5, a5, bb4, d5, bb5,
    g5, g4, c5, g4, e5, f5, g5, c5,
    g5, a4, c5, a5, f5, a4, e5, c5,
    f5, bb4, d5, bb4, d5, e5, f5, bb4,
    f5, g4, bb4, g4, d5, e5, f5, g4
  ])();
  melodyPattern([f5, cs5, e5, cs5, a5, cs5], a4)();
  melodyPattern([a5, a5], cs5)();
};

const melodySeqEnd = simpleMelodyPattern([d5, a4, e5, f5]);

const bassPattern = simpleMelodyPattern([
  d3, 0, 0, 0, 0, d3, e3, f3,
  g3, 0, 0, 0, 0, g3, a3, bb3,
  c4, 0, 0, 0, 0, bb3, a3, g3,
  f3, 0, g3, 0, a3, 0, f3, 0,
  bb3, 0, 0, 0, 0, c4, bb3, a3,
  g3, 0, 0, 0, 0, a3, bb3, g3,
  a3, 0, 0, 0, a3, 0, 0, 0,
  a3, 0, g3, 0, f3, 0, e3, 0
]);

loop(() => {
  melodySeq0();
  melodySeq0();
  melodySeq1();
  melodySeq1();
  melodySeqEnd();
  melodySeqEnd();
  melodySeqEnd();
  simpleMelodyPattern([d5, 0, 0, 0])();
}, { name: 'melody', amp: 0.5 }).connect(reverb.create());

loop(() => {
  sleep(32);
  bassPattern();
  bassPattern();
  bassPattern();
  simpleMelodyPattern([d3, 0, 0, 0, 0, 0, 0, 0])();
  sleep(4);
}, { name: 'bass', amp: 0.7 }).connect(reverb.create());

The song is "Wizards and Warriors (main menu)" by romaindurand on dittytoy. I've slowed it down a bit.

Sounds quite nice! But doing the reverb like this is a bit annoying, and it sounds a bit muffled. The reverb also directly affects the attack and decay times, which can also be a bit annoying.

Magic Chorus

Let's get rid of that reverb. The reverb is essentially several delayed versions of the original input, shuffled in order to make even more delays, as well as filtered versions passed back into the input. Because the delays are very short, it should in theory be possible to get away with a low amount of delays.

To smooth the saw wave a bit, we can use an anti-aliased saw, given with the following code:

function softclip(x) {
  return x < -1 ? -1 : x > 1 ? 1 : 1.5*(1-x*x/3)*x;
}

function varsaw(p, formant) {
  let x = p-~~p;
  return (x - 0.5) * softclip(formant*x*(1-x)) * 2;
}

The higher the value of formant, the less filtered the saw wave is.

Then, we can play this saw wave several times, but add a random phase offset to each wave. I do 8 random phase offsets for each channel, for a total of two channels. I also randomize the value of formant for each saw a bit.

This makes it sound like this, again, also on dittytoy:

Cello without reverb – Paused
Visualizer
Code (click to expand)
// inspired by https://www.youtube.com/watch?v=Aktb_dmY4vk
// essentially, pass a bandpassed saw wave with vibrato and a bit of noise into a short reverb
// 
// It is possible to not do the reverb, as the diffusion it does that creates the sound
// is essentially a number of parallel delays. A chorus of saw waves with random phases
// gives the same effect.

// bit slower, original is 210
ditty.bpm = 140;

input.tuning = 0.5; //min=0.125, max=2, step=0.125

input.adsr_attack = 0.05; //min=0.01, max=1, step=0.01
input.adsr_decay = 0.05; //min=0.01, max=4, step=0.01
input.adsr_sustain = 0.8; //min=0, max=1, step=0.01
input.adsr_release = 0.05; //min=0.01, max=4, step=0.01

input.fold = 0.2; //min=0.1, max=5, step=0.01

input.soft_base = 40; //min=0, max=50, step=1
input.soft_random = 20; //min=0, max=50, step=1

input.vibrato_amount = 3.5; //min=0, max=5, step=0.01
input.vibrato_freq = 32; //min=0, max=60, step=0.01

input.loss_freq = 500; //min=100, max=8000, step=1
input.loss_q = 1.3;//min=0.7, max=4, step=0.1
input.loss_wet = 0.5; //min=0, max=1, step=0.01

function softclip(x) {
  return x < -1 ? -1 : x > 1 ? 1 : 1.5 * (1 - x * x / 3) * x;
}

function varsaw(p, formant) {
  let x = p - ~~p;
  return (x - 0.5) * softclip(formant * x * (1 - x)) * 2;
}

/// envelope
class Env {
  constructor(sr) {
    this.dt = 1 / sr;
    this.v = 0;
    this.decaying = false;
  }

  tick(note_on, attack, decay, sustain, release) {
    if (note_on && this.v < 1.0 && !this.decaying) {
      this.v += this.dt / Math.max(attack, this.dt);
      this.v = Math.min(this.v, 1.0);
      return this.v;
    }
    if (note_on) {
      this.decaying = true;
      this.v += (sustain - this.v)
        * (1 - Math.exp(-Math.PI * 2 * this.dt / Math.max(decay, this.dt)));
      return this.v;
    }
    if (!note_on) {
      this.decaying = true;
      this.v -= this.v
        * (1 - Math.exp(-Math.PI * 2 * this.dt / Math.max(release, this.dt)));
      return this.v;
    }
  }
}

// SVF filter
// https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf
class Svf {
  constructor(sr) {
    this.dt = 1 / sr;
    this.ic1eq = 0;
    this.ic2eq = 0;
  }

  tick(v0, freq, q) {
    const g = Math.tan(Math.PI * freq * this.dt);
    const k = 1 / q;
    const a1 = 1 / (1 + g * (g + k));
    const a2 = g * a1;
    const a3 = g * a2;

    // tick
    const v3 = v0 - this.ic2eq;
    const v1 = a1 * this.ic1eq + a2 * v3;
    const v2 = this.ic2eq + a2 * this.ic1eq + a3 * v3;

    // update
    this.ic1eq = 2 * v1 - this.ic1eq;
    this.ic2eq = 2 * v2 - this.ic2eq;

    // bandpass output
    return v1;
  }
}

const osc = synth.def(
  class {
    constructor(options) {
      // envelope
      this.adsr = new Env(ditty.sampleRate);
      this.time = 0;

      // The value of the note argument of the play call is retrievable via options.note.
      this.phases = new Float32Array(16).map(_ => Math.random());
      this.damping = new Float32Array(16).map(_ => Math.random());

      // frequencies
      this.freq = midi_to_hz(options.note) * ditty.dt;
      this.vibrato = Math.random();

      // filters
      this.bpl = new Svf(ditty.sampleRate);
      this.bpr = new Svf(ditty.sampleRate);
    }
    process(note, env, tick, options) {
      // envelope
      this.time += ditty.dt;
      const val = this.adsr.tick(
        this.time < tick_to_second(options.duration),
        input.adsr_attack,
        input.adsr_decay,
        input.adsr_sustain,
        input.adsr_release
      );

      // vibrato
      this.vibrato += ditty.dt * input.vibrato_freq;

      // total
      let l = 0.0;
      let r = 0.0;

      // all saw waves
      for (let i = 0; i < this.phases.length; i++) {
        // sample wave
        const v = varsaw(this.phases[i], input.soft_base - this.damping[i] * input.soft_random) * val;

        // update phase
        const dt = this.freq + Math.sin(this.vibrato) * input.vibrato_amount * ditty.dt;
        this.phases[i] += dt * input.tuning;

        // stereo
        if (i < this.phases.length / 2) l += v;
        else r += v;
      }

      // folding
      l = Math.sin(l * input.fold) / input.fold;
      r = Math.sin(r * input.fold) / input.fold;

      // filter
      l = this.bpl.tick(l, input.loss_freq, input.loss_q) * input.loss_wet + (1 - input.loss_wet) * l;
      r = this.bpr.tick(r, input.loss_freq, input.loss_q) * input.loss_wet + (1 - input.loss_wet) * r;

      return [l * 0.707, r * 0.707];
    }
  }, {
  // attack parameters
  attack: 0.01,
  decay: 2.05,
  sustain: 0.0,
  release: 0.05,
  //env: adsr,
}
);

// === original ===
// Forked from "Wizards & Warriors (main menu)" by romaindurand
// https://dittytoy.net/ditty/3066954356

function melodyPattern(notes, baseNote) {
  return () => {
    for (i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
      osc.play(baseNote, { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

function simpleMelodyPattern(notes) {
  // calculate length for it to sound nice
  let lens = new Array(notes.length).fill(0.45);
  let last = 0;

  // every off note adds to the on length of the last on note
  for (let i = 0; i < lens.length; i++) {
    if (notes[i] === 0) lens[last] += 0.5;
    else last = i;
  }

  return () => {
    for (let i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: lens[i], pan: -0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

const melodySeq0 = () => {
  melodyPattern([d5, e5, f5, g5], a4)();
  melodyPattern([bb4, d5, g5, f5], g4)();
  melodyPattern([e5, f5], c5)();
  melodyPattern([g5, c5], g4)();
  melodyPattern([bb4, a4, f5, e5], f4)();

  const notes0 = [d5, e5, f5, d5];
  melodyPattern(notes0, bb4)();
  //same as previous pattern with another base note
  melodyPattern(notes0, g4)();

  melodyPattern([e5, cs5, e5, cs5], a4)();
  melodyPattern([a5, cs5, a5, a5], a4)();
};

const melodySeq1 = () => {
  melodyPattern([f5, d5], a4)();
  simpleMelodyPattern([f5, g5, a5, a4])();
  simpleMelodyPattern([
    a5, bb4, d5, bb5, a5, bb4, d5, bb5,
    g5, g4, c5, g4, e5, f5, g5, c5,
    g5, a4, c5, a5, f5, a4, e5, c5,
    f5, bb4, d5, bb4, d5, e5, f5, bb4,
    f5, g4, bb4, g4, d5, e5, f5, g4
  ])();
  melodyPattern([f5, cs5, e5, cs5, a5, cs5], a4)();
  melodyPattern([a5, a5], cs5)();
};

const melodySeqEnd = simpleMelodyPattern([d5, a4, e5, f5]);

const bassPattern = simpleMelodyPattern([
  d3, 0, 0, 0, 0, d3, e3, f3,
  g3, 0, 0, 0, 0, g3, a3, bb3,
  c4, 0, 0, 0, 0, bb3, a3, g3,
  f3, 0, g3, 0, a3, 0, f3, 0,
  bb3, 0, 0, 0, 0, c4, bb3, a3,
  g3, 0, 0, 0, 0, a3, bb3, g3,
  a3, 0, 0, 0, a3, 0, 0, 0,
  a3, 0, g3, 0, f3, 0, e3, 0
]);

loop(() => {
  melodySeq0();
  melodySeq0();
  melodySeq1();
  melodySeq1();
  melodySeqEnd();
  melodySeqEnd();
  melodySeqEnd();
  simpleMelodyPattern([d5, 0, 0, 0])();
}, { name: 'Melody', amp: 0.3 });

loop(() => {
  sleep(32);
  bassPattern();
  bassPattern();
  bassPattern();
  simpleMelodyPattern([d3, 0, 0, 0, 0, 0, 0, 0])();
  sleep(4);
}, { name: 'Bass', amp: 0.5 });

In my opinion this sounds a bit better, and it is also easier to control due to the lack of added attack and decay due to the filter. It's easy to implement, and in theory also easy to vectorize with SIMD (sadly not possible in JavaScript).

I have played around a bit with using different base waveforms than a saw wave, but so far this doesn't make that much of a difference.

It is also possible to decay the formant together with the amplitude, to recreate a plucked string instrument. Of course, also disable the vibrato:

Guitar-ish intstrument – Paused
Visualizer
Code (click to expand)
// inspired by https://www.youtube.com/watch?v=Aktb_dmY4vk
// essentially, pass a bandpassed saw wave with vibrato and a bit of noise into a short reverb
// 
// It is possible to not do the reverb, as the diffusion it does that creates the sound
// is essentially a number of parallel delays. A chorus of saw waves with random phases
// gives the same effect.

// bit slower, original is 210
ditty.bpm = 140;

input.tuning = 0.5; //min=0.125, max=2, step=0.125

input.adsr_attack = 0.01; //min=0.01, max=1, step=0.01
input.adsr_decay = 4.0; //min=0.01, max=4, step=0.01
input.adsr_sustain = 0.0; //min=0, max=1, step=0.01
input.adsr_release = 3.0; //min=0.01, max=4, step=0.01

input.fold = 0.2; //min=0.1, max=5, step=0.01

input.soft_base = 40; //min=0, max=50, step=1
input.soft_random = 20; //min=0, max=50, step=1

input.vibrato_amount = 3.5; //min=0, max=5, step=0.01
input.vibrato_freq = 0; //min=0, max=60, step=0.01

input.loss_freq = 500; //min=100, max=8000, step=1
input.loss_q = 1.3;//min=0.7, max=4, step=0.1
input.loss_wet = 0.5; //min=0, max=1, step=0.01

function softclip(x) {
  return x < -1 ? -1 : x > 1 ? 1 : 1.5 * (1 - x * x / 3) * x;
}

function varsaw(p, formant) {
  let x = p - ~~p;
  return (x - 0.5) * softclip(formant * x * (1 - x)) * 2;
}

/// envelope
class Env {
  constructor(sr) {
    this.dt = 1 / sr;
    this.v = 0;
    this.decaying = false;
  }

  tick(note_on, attack, decay, sustain, release) {
    if (note_on && this.v < 1.0 && !this.decaying) {
      this.v += this.dt / Math.max(attack, this.dt);
      this.v = Math.min(this.v, 1.0);
      return this.v;
    }
    if (note_on) {
      this.decaying = true;
      this.v += (sustain - this.v)
        * (1 - Math.exp(-Math.PI * 2 * this.dt / Math.max(decay, this.dt)));
      return this.v;
    }
    if (!note_on) {
      this.decaying = true;
      this.v -= this.v
        * (1 - Math.exp(-Math.PI * 2 * this.dt / Math.max(release, this.dt)));
      return this.v;
    }
  }
}

// SVF filter
// https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf
class Svf {
  constructor(sr) {
    this.dt = 1 / sr;
    this.ic1eq = 0;
    this.ic2eq = 0;
  }

  tick(v0, freq, q) {
    const g = Math.tan(Math.PI * freq * this.dt);
    const k = 1 / q;
    const a1 = 1 / (1 + g * (g + k));
    const a2 = g * a1;
    const a3 = g * a2;

    // tick
    const v3 = v0 - this.ic2eq;
    const v1 = a1 * this.ic1eq + a2 * v3;
    const v2 = this.ic2eq + a2 * this.ic1eq + a3 * v3;

    // update
    this.ic1eq = 2 * v1 - this.ic1eq;
    this.ic2eq = 2 * v2 - this.ic2eq;

    // bandpass output
    return v1;
  }
}

const osc = synth.def(
  class {
    constructor(options) {
      // envelope
      this.adsr = new Env(ditty.sampleRate);
      this.time = 0;

      // The value of the note argument of the play call is retrievable via options.note.
      this.phases = new Float32Array(16).map(_ => Math.random());
      this.damping = new Float32Array(16).map(_ => Math.random());

      // frequencies
      this.freq = midi_to_hz(options.note) * ditty.dt;
      this.vibrato = Math.random();

      // filters
      this.bpl = new Svf(ditty.sampleRate);
      this.bpr = new Svf(ditty.sampleRate);
    }
    process(note, env, tick, options) {
      // envelope
      this.time += ditty.dt;
      const val = this.adsr.tick(
        this.time < tick_to_second(options.duration),
        input.adsr_attack,
        input.adsr_decay,
        input.adsr_sustain,
        input.adsr_release
      );

      // vibrato
      this.vibrato += ditty.dt * input.vibrato_freq;

      // total
      let l = 0.0;
      let r = 0.0;

      // all saw waves
      for (let i = 0; i < this.phases.length; i++) {
        // sample wave
        const v = varsaw(this.phases[i], (input.soft_base - this.damping[i] * input.soft_random) * val) * val;

        // update phase
        const dt = this.freq + Math.sin(this.vibrato) * input.vibrato_amount * ditty.dt;
        this.phases[i] += dt * input.tuning + ditty.dt * (Math.random() * 2 - 1) * 8;

        // stereo
        if (i < this.phases.length / 2) l += v;
        else r += v;
      }

      // folding
      l = Math.sin(l * input.fold) / input.fold;
      r = Math.sin(r * input.fold) / input.fold;

      // filter
      l = this.bpl.tick(l, input.loss_freq, input.loss_q) * input.loss_wet + (1 - input.loss_wet) * l;
      r = this.bpr.tick(r, input.loss_freq, input.loss_q) * input.loss_wet + (1 - input.loss_wet) * r;

      return [l * 0.707, r * 0.707];
    }
  }, {
  // attack parameters
  attack: 0.01,
  decay: 2.05,
  sustain: 0.0,
  release: 0.05,
  //env: adsr,
}
);

// === original ===
// Forked from "Wizards & Warriors (main menu)" by romaindurand
// https://dittytoy.net/ditty/3066954356

function melodyPattern(notes, baseNote) {
  return () => {
    for (i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
      osc.play(baseNote, { duration: 0.5, pan: 0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

function simpleMelodyPattern(notes) {
  // calculate length for it to sound nice
  let lens = new Array(notes.length).fill(0.45);
  let last = 0;

  // every off note adds to the on length of the last on note
  for (let i = 0; i < lens.length; i++) {
    if (notes[i] === 0) lens[last] += 0.5;
    else last = i;
  }

  return () => {
    for (let i = 0; i < notes.length; i++) {
      osc.play(notes[i], { duration: lens[i], pan: -0.2 - Math.random() * 0.1 });
      sleep(0.5);
    }
  };
}

const melodySeq0 = () => {
  melodyPattern([d5, e5, f5, g5], a4)();
  melodyPattern([bb4, d5, g5, f5], g4)();
  melodyPattern([e5, f5], c5)();
  melodyPattern([g5, c5], g4)();
  melodyPattern([bb4, a4, f5, e5], f4)();

  const notes0 = [d5, e5, f5, d5];
  melodyPattern(notes0, bb4)();
  //same as previous pattern with another base note
  melodyPattern(notes0, g4)();

  melodyPattern([e5, cs5, e5, cs5], a4)();
  melodyPattern([a5, cs5, a5, a5], a4)();
};

const melodySeq1 = () => {
  melodyPattern([f5, d5], a4)();
  simpleMelodyPattern([f5, g5, a5, a4])();
  simpleMelodyPattern([
    a5, bb4, d5, bb5, a5, bb4, d5, bb5,
    g5, g4, c5, g4, e5, f5, g5, c5,
    g5, a4, c5, a5, f5, a4, e5, c5,
    f5, bb4, d5, bb4, d5, e5, f5, bb4,
    f5, g4, bb4, g4, d5, e5, f5, g4
  ])();
  melodyPattern([f5, cs5, e5, cs5, a5, cs5], a4)();
  melodyPattern([a5, a5], cs5)();
};

const melodySeqEnd = simpleMelodyPattern([d5, a4, e5, f5]);

const bassPattern = simpleMelodyPattern([
  d3, 0, 0, 0, 0, d3, e3, f3,
  g3, 0, 0, 0, 0, g3, a3, bb3,
  c4, 0, 0, 0, 0, bb3, a3, g3,
  f3, 0, g3, 0, a3, 0, f3, 0,
  bb3, 0, 0, 0, 0, c4, bb3, a3,
  g3, 0, 0, 0, 0, a3, bb3, g3,
  a3, 0, 0, 0, a3, 0, 0, 0,
  a3, 0, g3, 0, f3, 0, e3, 0
]);

loop(() => {
  melodySeq0();
  melodySeq0();
  melodySeq1();
  melodySeq1();
  melodySeqEnd();
  melodySeqEnd();
  melodySeqEnd();
  simpleMelodyPattern([d5, 0, 0, 0])();
}, { name: 'Melody', amp: 0.3 });

loop(() => {
  sleep(32);
  bassPattern();
  bassPattern();
  bassPattern();
  simpleMelodyPattern([d3, 0, 0, 0, 0, 0, 0, 0])();
  sleep(4);
}, { name: 'Bass', amp: 0.5 });

For this, I also added a slight random frequency offset when increasing the phase (~8Hz at most) for each sample. This changes the sound ever so slightly but makes it a bit richer to my ears.

I'd like to eventually recreate a piano, but this is supposedly "very hard". 9b0 did manage it somehow.

Of course, there's also paid VST plugins that can simulate a piano, such as Pianoteq. This works based on modal synthesis, from my understanding, so it is possible.

And a thank you to Reinder Nijhoff, for making the code part of dittytoy self-hostable, so I can embed it in this site