Each dot below is a grain — a brief burst of sound lasting just a few milliseconds. Adjust the controls to change how many grains fire per second, how wide their pitch is spread, and how long each one lasts.
<!-- Granular synthesis demo — a visual grain cloud -->
<p class="hint">Each dot is a <b>grain</b> — a millisecond-long burst of sound. Adjust density, pitch scatter, and duration to shape the cloud from sparse pulses to a dense, shimmering texture.</p>
<canvas id="canvas" width="560" height="240" aria-label="Grain cloud visualization"></canvas>
<div class="controls">
<label>Density (grains/s):<input type="range" id="density" min="1" max="60" value="20"></label>
<label>Pitch scatter (%):<input type="range" id="pitch" min="0" max="100" value="30"></label>
<label>Duration (ms):<input type="range" id="duration" min="5" max="120" value="40"></label>
</div>
<div class="status" id="status"></div>
<div class="btns">
<button id="toggle" type="button">Start cloud</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and grain-cloud styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
canvas { display: block; background: #0d1117; border-radius: 8px; width: 100%; max-width: 560px; margin: 0 auto .8rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; font-size: .88rem; color: #333; }
label { display: flex; align-items: center; gap: .6rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; color: #1d6a3a; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grain model: each grain has position, colour (hue encodes pitch), radius, and remaining life. */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* grain pool and playback state */
const grains = [];
let running = false;
let animId = null;
let grainCount = 0;
function readControls() {
return {
density: parseInt(document.getElementById('density').value, 10),
pitch: parseInt(document.getElementById('pitch').value, 10),
dur: parseInt(document.getElementById('duration').value, 10)
};
}
/* Map pitch fraction [0,1] to a hue in the blue-green range */
function grainHue(pitchFrac) {
return Math.round(180 + pitchFrac * 140);
}
/* Create one grain with randomised position and pitch-tinted colour */
function spawnGrain(p) {
const pitchFrac = 0.5 + (Math.random() - 0.5) * (p.pitch / 100);
const x = Math.random() * W;
const y = H * 0.5 + (Math.random() - 0.5) * H * 0.6;
const life = p.dur * 16;
grains.push({ x, y, life, maxLife: life, hue: grainHue(pitchFrac), r: 3 + Math.random() * 3 });
grainCount++;
}
/* Clear canvas and paint all living grains, fading in/out at edges of lifetime */
function draw() {
ctx.clearRect(0, 0, W, H);
/* subtle trail fade */
ctx.fillStyle = 'rgba(13, 17, 23, 0.18)';
ctx.fillRect(0, 0, W, H);
for (let i = grains.length - 1; i >= 0; i--) {
const g = grains[i];
const t = g.life / g.maxLife;
/* envelope fade-in / sustain / fade-out */
const alpha = t < 0.15 ? t / 0.15 : t > 0.8 ? (1 - t) / 0.2 : 1;
ctx.beginPath();
ctx.arc(g.x + Math.sin(g.life * 0.04) * 4, g.y, g.r * (0.7 + 0.3 * t), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${g.hue},70%,65%,${alpha.toFixed(2)})`;
ctx.fill();
g.life--;
if (g.life <= 0) grains.splice(i, 1);
}
}
let spawnAccum = 0;
let lastTs = null;
/* Animation loop — spawn grains at the requested density, draw, then repeat */
function loop(ts) {
if (!running) return;
const dt = lastTs === null ? 16 : Math.min(ts - lastTs, 64);
lastTs = ts;
const p = readControls();
/* accumulate fractional grains to fire at exact density */
spawnAccum += p.density * dt / 1000;
while (spawnAccum >= 1) { spawnGrain(p); spawnAccum--; }
draw();
updateStatus(p);
animId = requestAnimationFrame(loop);
}
function updateStatus(p) {
const active = grains.length;
document.getElementById('status').textContent =
i18nFmt('%active% grains active · %total% spawned so far', { active, total: grainCount });
}
/* Button handlers — toggle starts/pauses the cloud, reset clears it */
document.getElementById('toggle').addEventListener('click', () => {
running = !running;
const btn = document.getElementById('toggle');
if (running) {
lastTs = null;
btn.textContent = 'Pause';
animId = requestAnimationFrame(loop);
} else {
cancelAnimationFrame(animId);
btn.textContent = 'Start cloud';
}
});
document.getElementById('reset').addEventListener('click', () => {
grains.length = 0;
grainCount = 0;
spawnAccum = 0;
ctx.clearRect(0, 0, W, H);
document.getElementById('status').textContent = '';
});
/* First paint (shows an empty dark canvas before start) */
draw();
<!-- Granular synthesis demo — a visual grain cloud -->
<p class="hint">Each dot is a <b>grain</b> — a millisecond-long burst of sound. Adjust density, pitch scatter, and duration to shape the cloud from sparse pulses to a dense, shimmering texture.</p>
<canvas id="canvas" width="560" height="240" aria-label="Grain cloud visualization"></canvas>
<div class="controls">
<label>Density (grains/s):<input type="range" id="density" min="1" max="60" value="20"></label>
<label>Pitch scatter (%):<input type="range" id="pitch" min="0" max="100" value="30"></label>
<label>Duration (ms):<input type="range" id="duration" min="5" max="120" value="40"></label>
</div>
<div class="status" id="status"></div>
<div class="btns">
<button id="toggle" type="button">Start cloud</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and grain-cloud styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
canvas { display: block; background: #0d1117; border-radius: 8px; width: 100%; max-width: 560px; margin: 0 auto .8rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; font-size: .88rem; color: #333; }
label { display: flex; align-items: center; gap: .6rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; color: #1d6a3a; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grain model: each grain has position, colour (hue encodes pitch), radius, and remaining life. */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* grain pool and playback state */
const grains = [];
let running = false;
let animId = null;
let grainCount = 0;
function readControls() {
return {
density: parseInt(document.getElementById('density').value, 10),
pitch: parseInt(document.getElementById('pitch').value, 10),
dur: parseInt(document.getElementById('duration').value, 10)
};
}
/* Map pitch fraction [0,1] to a hue in the blue-green range */
function grainHue(pitchFrac) {
return Math.round(180 + pitchFrac * 140);
}
/* Create one grain with randomised position and pitch-tinted colour */
function spawnGrain(p) {
const pitchFrac = 0.5 + (Math.random() - 0.5) * (p.pitch / 100);
const x = Math.random() * W;
const y = H * 0.5 + (Math.random() - 0.5) * H * 0.6;
const life = p.dur * 16;
grains.push({ x, y, life, maxLife: life, hue: grainHue(pitchFrac), r: 3 + Math.random() * 3 });
grainCount++;
}
/* Clear canvas and paint all living grains, fading in/out at edges of lifetime */
function draw() {
ctx.clearRect(0, 0, W, H);
/* subtle trail fade */
ctx.fillStyle = 'rgba(13, 17, 23, 0.18)';
ctx.fillRect(0, 0, W, H);
for (let i = grains.length - 1; i >= 0; i--) {
const g = grains[i];
const t = g.life / g.maxLife;
/* envelope fade-in / sustain / fade-out */
const alpha = t < 0.15 ? t / 0.15 : t > 0.8 ? (1 - t) / 0.2 : 1;
ctx.beginPath();
ctx.arc(g.x + Math.sin(g.life * 0.04) * 4, g.y, g.r * (0.7 + 0.3 * t), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${g.hue},70%,65%,${alpha.toFixed(2)})`;
ctx.fill();
g.life--;
if (g.life <= 0) grains.splice(i, 1);
}
}
let spawnAccum = 0;
let lastTs = null;
/* Animation loop — spawn grains at the requested density, draw, then repeat */
function loop(ts) {
if (!running) return;
const dt = lastTs === null ? 16 : Math.min(ts - lastTs, 64);
lastTs = ts;
const p = readControls();
/* accumulate fractional grains to fire at exact density */
spawnAccum += p.density * dt / 1000;
while (spawnAccum >= 1) { spawnGrain(p); spawnAccum--; }
draw();
updateStatus(p);
animId = requestAnimationFrame(loop);
}
function updateStatus(p) {
const active = grains.length;
document.getElementById('status').textContent =
i18nFmt('%active% grains active · %total% spawned so far', { active, total: grainCount });
}
/* Button handlers — toggle starts/pauses the cloud, reset clears it */
document.getElementById('toggle').addEventListener('click', () => {
running = !running;
const btn = document.getElementById('toggle');
if (running) {
lastTs = null;
btn.textContent = 'Pause';
animId = requestAnimationFrame(loop);
} else {
cancelAnimationFrame(animId);
btn.textContent = 'Start cloud';
}
});
document.getElementById('reset').addEventListener('click', () => {
grains.length = 0;
grainCount = 0;
spawnAccum = 0;
ctx.clearRect(0, 0, W, H);
document.getElementById('status').textContent = '';
});
/* First paint (shows an empty dark canvas before start) */
draw();
Notice what happens at extremes. With very few grains per second you can hear the individual bursts — the rhythm of scheduling becomes audible. Pack them densely enough and the ear fuses them into a seamless cloud. Density, pitch scatter, and grain duration are the three knobs that turn raw milliseconds into any texture from airy shimmer to grinding noise.
Comments
Loading comments...