← AI

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

  1. Paste the snippet just before </head>.
  2. Add data-voice-waveform to an element with a width and height. The bars take its text colour.
  3. Add data-mic to let visitors tap it and speak; the browser asks for permission first.

Options

Add any of these attributes to the same element.

data-stylebars (default) or wave.
data-barsHow many bars. Default fits the width, 7 to 64.
data-colorBar colour. Default the text colour.
data-colorsWave colours, comma-separated. Default #8b5cf6,#ec4899,#22d3ee.
data-micTap or click (or press Enter) to react to the microphone instead of the pretend voice.
data-sensitivityHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>