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.
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.
<!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.delayTimeis three eighth-notes, so the echo falls off the beat; trystepLen() * 2to 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.