Voice Waveform
Rounded bars that jump and settle with speech, tallest in the middle, like a voice assistant listening. Or pick the wave style: three coloured waves that swell together. It runs on a pretend voice, and with data-mic a tap switches it to your real microphone.
Tap the bars to use your microphone
How to use it
- Paste the snippet just before
</head>. - Add
data-voice-waveformto an element with a width and height. The bars take its text colour. - Add
data-micto let visitors tap it and speak; the browser asks for permission first.
Options
Add any of these attributes to the same element.
data-style | bars (default) or wave. |
data-bars | How many bars. Default fits the width, 7 to 64. |
data-color | Bar colour. Default the text colour. |
data-colors | Wave colours, comma-separated. Default #8b5cf6,#ec4899,#22d3ee. |
data-mic | Tap or click (or press Enter) to react to the microphone instead of the pretend voice. |
data-sensitivity | How big the movement is. Default 1. |
The microphone only works on pages served over HTTPS (or localhost). Sound never leaves the visitor’s browser: it’s only measured to set the bar heights.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Voice Waveform: rounded bars (or flowing waves) that jump and settle with
// speech, like a voice assistant listening. It runs on a pretend voice by
// default; add data-mic and a tap or click switches it to your real
// microphone (the browser asks first). Drawn on a canvas, paused when off screen.
// Usage: <div data-voice-waveform style="height:120px"></div>
// Optional: data-style ("bars", default, or "wave"), data-bars (how many
// bars, default fits the width, 7 to 64), data-color (bar colour, default
// the text colour), data-colors (wave colours, comma-separated, default
// "#8b5cf6,#ec4899,#22d3ee"), data-mic (tap to use the microphone),
// data-sensitivity (how big the movement is, default 1).
var DEFAULTS = { style: 'bars', colors: '#8b5cf6,#ec4899,#22d3ee', sensitivity: 1 };
var css =
'[data-voice-waveform]{position:relative}' +
'[data-voice-waveform][data-mic]{cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.vwf-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A pretend voice: syllables of varying loudness with gaps between words.
function voice() {
var target = 0, until = 0;
return function (t) {
if (t > until) {
var r = Math.random();
if (r < 0.1) { target = 0.02; until = t + 350 + Math.random() * 450; }
else if (r < 0.3) { target = 0.1; until = t + 80 + Math.random() * 120; }
else { target = 0.4 + Math.random() * 0.6; until = t + 70 + Math.random() * 150; }
}
return target;
};
}
function build(el) {
var d = el.dataset;
var mode = d.style || DEFAULTS.style;
var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var sens = +d.sensitivity || DEFAULTS.sensitivity;
var canvas = document.createElement('canvas');
canvas.className = 'vwf-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1, t = 0, last = 0, raf = 0, visible = false;
var n = 0, vals = [], seeds = [], level = 0, fake = voice();
var analyser = null, freq = null;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth; h = el.clientHeight;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
n = +d.bars || Math.max(7, Math.min(64, Math.round(w / 14)));
while (vals.length < n) { vals.push(0.08); seeds.push(Math.random() * 100); }
vals.length = seeds.length = n;
draw();
}
// Target heights for each bar, 0–1.
function targets() {
var out = [];
if (analyser) {
analyser.getByteFrequencyData(freq);
// Spread the voice range (the lower part of the spectrum) over the
// bars, mirrored so the middle bars carry the loudest bands.
var half = Math.ceil(n / 2), span = Math.floor(freq.length * 0.45);
for (var i = 0; i < n; i++) {
var k = Math.abs(i - (n - 1) / 2) / ((n - 1) / 2 || 1);
var b = Math.floor(k * (half / n) * 2 * span * 0.95);
out.push(Math.min(1, (freq[b] / 255) * 1.2 * sens));
}
return out;
}
var target = fake(t);
level += (target - level) * 0.25;
for (var j = 0; j < n; j++) {
var c = Math.abs(j - (n - 1) / 2) / ((n - 1) / 2 || 1);
var shape = 1 - c * c * 0.75; // taller in the middle
var jitter = 0.55 + 0.45 * Math.sin(t * 0.011 + seeds[j]) * Math.sin(t * 0.0047 + seeds[j] * 2);
out.push(Math.min(1, level * shape * (0.6 + jitter * 0.6) * sens));
}
return out;
}
function drawBars() {
var gap = Math.max(3, (w / n) * 0.42), bw = (w - gap * (n - 1)) / n;
bw = Math.min(bw, 14);
var total = n * bw + (n - 1) * gap, x0 = (w - total) / 2;
ctx.fillStyle = d.color || getComputedStyle(el).color;
for (var i = 0; i < n; i++) {
var bh = Math.max(bw, vals[i] * h * 0.92);
var x = x0 + i * (bw + gap), y = (h - bh) / 2;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, bw / 2);
else ctx.rect(x, y, bw, bh);
ctx.fill();
}
}
function drawWave() {
var avg = 0;
for (var i = 0; i < n; i++) avg += vals[i];
avg /= n || 1;
var amp = h * 0.46 * Math.max(0.06, Math.min(1, avg * 2.6));
ctx.globalCompositeOperation = 'lighter';
cols.forEach(function (c, k) {
var f = 1.4 + k * 0.55, sp = 0.0032 + k * 0.0011, ph = k * 1.7;
ctx.beginPath();
for (var x = 0; x <= w; x += 3) {
var u = x / w;
var env = Math.pow(Math.sin(u * Math.PI), 2); // pinched at both ends
var y = h / 2 + Math.sin(u * Math.PI * 2 * f + t * sp + ph) * amp * env * (1 - k * 0.18);
if (x) ctx.lineTo(x, y); else ctx.moveTo(x, y);
}
ctx.globalAlpha = 0.9;
ctx.strokeStyle = c;
ctx.lineWidth = 2;
ctx.stroke();
// Fill between the curve and the centre line.
ctx.lineTo(w, h / 2);
ctx.lineTo(0, h / 2);
ctx.closePath();
ctx.globalAlpha = 0.4;
ctx.fillStyle = c;
ctx.fill();
});
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (!w || !h) return;
if (mode === 'wave') drawWave(); else drawBars();
}
function frame(now) {
raf = 0;
if (!visible) return;
var dt = last ? Math.min(50, now - last) : 16;
last = now; t += dt;
var tg = targets();
for (var i = 0; i < n; i++) {
// Rise fast, fall slower, like a level meter.
var k = tg[i] > vals[i] ? 0.45 : 0.14;
vals[i] += (tg[i] - vals[i]) * k;
}
draw();
raf = requestAnimationFrame(frame);
}
function start() { if (!raf && !reduced()) { last = 0; raf = requestAnimationFrame(frame); } }
if (d.mic !== undefined && !el.closest('[aria-hidden="true"]')) {
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Use my microphone');
var useMic = function () {
if (analyser || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
var Ctx = window.AudioContext || window.webkitAudioContext;
var ac = new Ctx();
var a = ac.createAnalyser();
a.fftSize = 256;
a.smoothingTimeConstant = 0.6;
ac.createMediaStreamSource(stream).connect(a);
freq = new Uint8Array(a.frequencyBinCount);
analyser = a;
el.setAttribute('aria-label', 'Listening to your microphone');
}).catch(function () {});
};
el.addEventListener('click', useMic);
el.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); useMic(); } });
}
el._vwf = true;
new ResizeObserver(resize).observe(el);
resize();
if (reduced()) {
// A still frame: a calm, mid-height shape.
for (var i = 0; i < n; i++) vals[i] = 0.2 + 0.25 * Math.sin((i / Math.max(1, n - 1)) * Math.PI);
draw();
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) start();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-voice-waveform]').forEach(function (el) {
if (el._vwf) return;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-voice-waveform style="height: 100px; color: #8b5cf6"></div>
<!-- waves, and a real microphone on tap: -->
<div data-voice-waveform data-style="wave" data-mic style="height: 100px"></div>