Chris Collins

Lab · Playground

A generative melody box

A WebAudio toy that improvises a melody with a random walk over a pentatonic scale, plus a drone and an echo — silent until you press Play.

Code blocks on this page are editable — change them and press Run.

Nothing here is recorded. Every note is made by the Web Audio API: an oscillator (a raw waveform) shaped by a gain envelope (a quick rise and a slow fall, so it sounds plucked rather than switched on), passed through a low-pass filter to soften it, and fed into a delay whose output loops back into itself for an echo.

The melody is a random walk on a pentatonic scale: each step usually moves one or two notes up or down, sometimes leaps, sometimes rests. A pentatonic scale has no semitone clashes, so any sequence of its notes sounds reasonable — which is why it is the scale of choice for wind chimes. A low drone on the root note holds it together, and the scheduler looks a little ahead of the clock so the timing stays steady even when the page is busy.

Browsers only allow sound after you interact with the page, so nothing plays until you press Play — the audio context is created inside that click. Turn your volume down first.

Random-walk melody with Web Audio
<!doctype html>
<style>
  html, body { margin: 0; height: 100%; background: #13111c; color: #e6e1f5; font: 13px system-ui, sans-serif; }
  body { display: flex; flex-direction: column; gap: 6px; padding: 8px; box-sizing: border-box; }
  .bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
  button, select { font: inherit; padding: 4px 10px; border-radius: 4px; border: 1px solid #554a77; background: #241f36; color: inherit; }
  button.play { background: #6b4fd8; border-color: #6b4fd8; color: #fff; }
  label { display: flex; align-items: center; gap: 4px; }
  input[type=range] { width: 90px; }
  canvas { flex: 1; min-height: 0; width: 100%; border-radius: 6px; background: #0b0a12; }
</style>
<div class="bar">
  <button class="play" id="play">▶ Play</button>
  <select id="scale" aria-label="Scale">
    <option value="0,2,4,7,9">major pentatonic</option>
    <option value="0,3,5,7,10">minor pentatonic</option>
    <option value="0,2,3,7,8">hirajoshi</option>
    <option value="0,2,4,6,8,10">whole tone</option>
  </select>
  <label>tempo <input id="tempo" type="range" min="60" max="200" value="112"></label>
  <label>echo <input id="echo" type="range" min="0" max="80" value="40"></label>
</div>
<canvas id="c"></canvas>
<script>
// Things to change: ROOT, WAVE, the step probabilities in nextNote(), the envelope in pluck().
const ROOT = 220;            // A3, in hertz
const WAVE = 'triangle';     // 'sine', 'square', 'sawtooth', 'triangle'
const OCTAVES = 2;
const LOOKAHEAD = 0.12;      // seconds of notes scheduled ahead of the clock

let ac = null, master, delay, feedback, drone = null;
let playing = false, nextTime = 0, degree = 5, timer = 0;
const notes = [];            // what has been played, for the picture

const $ = id => document.getElementById(id);
const scale = () => $('scale').value.split(',').map(Number);
const stepLen = () => 60 / $('tempo').value / 2;           // eighth notes

function setupAudio() {
  // Created on the first click: browsers refuse to start audio before a user gesture.
  ac = new AudioContext();
  master = ac.createGain(); master.gain.value = 0.25;
  delay = ac.createDelay(1); feedback = ac.createGain();
  const wet = ac.createGain(); wet.gain.value = 0.5;
  master.connect(ac.destination);
  master.connect(delay); delay.connect(feedback); feedback.connect(delay);
  delay.connect(wet); wet.connect(ac.destination);
}

function freq(deg) {
  const s = scale(), oct = Math.floor(deg / s.length), i = ((deg % s.length) + s.length) % s.length;
  return ROOT * Math.pow(2, oct + s[i] / 12);
}

function pluck(f, t, len) {
  const o = ac.createOscillator(), g = ac.createGain(), lp = ac.createBiquadFilter();
  o.type = WAVE; o.frequency.value = f;
  lp.type = 'lowpass'; lp.frequency.value = f * 4;
  g.gain.setValueAtTime(0, t);
  g.gain.linearRampToValueAtTime(0.6, t + 0.01);           // attack
  g.gain.exponentialRampToValueAtTime(0.001, t + len);     // decay
  o.connect(lp); lp.connect(g); g.connect(master);
  o.start(t); o.stop(t + len + 0.05);
}

function nextNote() {
  const r = Math.random(), max = scale().length * OCTAVES;
  if (r < 0.15) return null;                               // rest
  if (r < 0.75) degree += Math.random() < 0.5 ? -1 : 1;    // step
  else if (r < 0.92) degree += Math.random() < 0.5 ? -2 : 2;
  else degree += Math.floor(Math.random() * 7) - 3;        // leap
  if (degree < 0) degree = 1;
  if (degree > max) degree = max - 1;
  return degree;
}

function schedule() {
  delay.delayTime.value = stepLen() * 3;
  feedback.gain.value = $('echo').value / 100;
  while (nextTime < ac.currentTime + LOOKAHEAD) {
    const d = nextNote();
    if (d !== null) {
      const len = stepLen() * (Math.random() < 0.2 ? 4 : 2);
      pluck(freq(d), nextTime, len);
      notes.push({ d, t: nextTime, len });
    }
    nextTime += stepLen();
  }
}

function startDrone() {
  const o = ac.createOscillator(), g = ac.createGain();
  o.type = 'sine'; o.frequency.value = ROOT / 2;
  g.gain.setValueAtTime(0, ac.currentTime);
  g.gain.linearRampToValueAtTime(0.25, ac.currentTime + 2);
  o.connect(g); g.connect(master); o.start();
  drone = { o, g };
}

$('play').onclick = async () => {
  if (!ac) setupAudio();
  if (ac.state === 'suspended') await ac.resume();
  playing = !playing;
  $('play').textContent = playing ? '■ Stop' : '▶ Play';
  if (playing) {
    nextTime = ac.currentTime + 0.05;
    startDrone();
    timer = setInterval(schedule, 25);
  } else {
    clearInterval(timer);
    if (drone) { drone.g.gain.setTargetAtTime(0, ac.currentTime, 0.2); drone.o.stop(ac.currentTime + 1); drone = null; }
  }
};

// The picture: a piano roll that scrolls left; it waits quietly until you press Play.
const cv = $('c'), ctx = cv.getContext('2d');
function draw() {
  const dpr = devicePixelRatio || 1;
  const w = cv.clientWidth, h = cv.clientHeight;
  if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = '#0b0a12'; ctx.fillRect(0, 0, w, h);
  const rows = scale().length * OCTAVES + 1, rowH = h / rows;
  ctx.strokeStyle = '#1e1b2c';
  for (let i = 0; i <= rows; i++) { ctx.beginPath(); ctx.moveTo(0, i * rowH); ctx.lineTo(w, i * rowH); ctx.stroke(); }
  if (!ac) {
    ctx.fillStyle = '#8a80b0'; ctx.textAlign = 'center';
    ctx.fillText('Press Play to start the sound', w / 2, h / 2);
  } else {
    const now = ac.currentTime, pxPerSec = 80;
    while (notes.length && notes[0].t + notes[0].len < now - w / pxPerSec) notes.shift();
    for (const n of notes) {
      const x = w * 0.75 + (n.t - now) * pxPerSec, y = h - (n.d + 1) * rowH;
      const on = n.t <= now && now < n.t + n.len;
      ctx.fillStyle = `hsl(${260 + n.d * 9}, 75%, ${on ? 72 : 52}%)`;
      ctx.fillRect(x, y + 1, Math.max(3, n.len * pxPerSec - 2), rowH - 2);
    }
    ctx.fillStyle = '#ffffff55'; ctx.fillRect(w * 0.75, 0, 1, h);   // "now"
  }
  requestAnimationFrame(draw);
}
draw();
</script>

Things to change

  • WAVE = 'square' for a chiptune sound, 'sine' for a soft music box.

  • The probabilities in nextNote() are the melody’s personality: more steps sound singable, more leaps sound restless, more rests leave space for the echo.

  • The envelope in pluck(): a slower attack (t + 0.2) turns plucks into pads.

  • delay.delayTime is three eighth-notes, so the echo falls off the beat; try stepLen() * 2 to land it on the beat.

  • Add a second voice: call pluck(freq(d - 5), nextTime, len) as well, for a line an octave (in a five-note scale) below.