← AI

Live Transcript

Spoken words appear as they’re heard, pale and slightly blurred while they’re still a guess, then firm up into solid text a few words behind, the way live captions settle. Misheard words show the wrong guess first and quietly correct themselves, and a red dot pulses at the end while it listens.

MMaya

So the plan is to meet at the station at ten, grab a coffee, and catch the quarter past train down to the coast.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-live-transcript to a paragraph holding the finished transcript.
  3. List a few misheard words in data-fixes to have them correct themselves as they firm up.

Options

Add any of these attributes to the same element.

data-fixesMisheard guesses as word=guess pairs, separated by commas, e.g. ten=then, meet=meat.
data-rateWords heard each second. Default 3.2.
data-lagHow many of the newest words stay unsure. Default 3. Everything firms up at a full stop.
data-loopClear and start again after a pause.
data-holdHow long the finished transcript shows before looping. Default 2600 ms.
data-colorColour of the live dot. Default #ef4444.

Screen readers get the whole transcript straight away, as the element’s label.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Live Transcript: spoken words appear as they're heard, pale and a little
    // blurred while they're still a guess, then firm up into solid text a few
    // words behind, the way live captions settle. Misheard words can show a
    // wrong guess first and correct themselves as they firm up.
    // Usage: <p data-live-transcript>So the plan is to meet at the station at ten.</p>
    // Optional: data-fixes (misheard guesses, as word=guess pairs separated by
    // commas, e.g. "ten=then, meet=meat"), data-rate (words a second, default
    // 3.2), data-lag (how many words stay unsure, default 3), data-loop (clear
    // and start again), data-hold (ms the finished transcript shows before
    // looping, default 2600), data-color (colour of the live dot, default #ef4444).
    var DEFAULTS = { rate: 3.2, lag: 3, hold: 2600, color: '#ef4444' };

    var css =
      '.ltr-w{opacity:0}' +
      '.ltr-heard{opacity:.45;filter:blur(.6px);font-style:italic;transition:opacity .45s,filter .45s,color .45s}' +
      '.ltr-firm{opacity:1;filter:none;font-style:normal;transition:opacity .45s,filter .45s,color .45s}' +
      '.ltr-dot{display:inline-block;width:.5em;height:.5em;margin-left:.3em;border-radius:50%;vertical-align:.1em;' +
      'background:var(--ltr-c);box-shadow:0 0 0 0 var(--ltr-c);animation:ltr-pulse 1.3s ease-out infinite}' +
      '.ltr-off .ltr-dot{animation:none;opacity:0;transition:opacity .4s}' +
      '@keyframes ltr-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ltr-c) 60%,transparent)}' +
      '100%{box-shadow:0 0 0 .55em transparent}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var text = el.textContent.trim().replace(/\s+/g, ' ');
      var fixes = {};
      (d.fixes || '').split(',').forEach(function (p) {
        var kv = p.split('=');
        if (kv.length === 2) fixes[kv[0].trim().toLowerCase()] = kv[1].trim();
      });
      el.setAttribute('aria-label', text);
      el.style.setProperty('--ltr-c', d.color || DEFAULTS.color);
      el.textContent = '';
      var words = text.split(' ').map(function (w, i, all) {
        var s = document.createElement('span');
        s.className = 'ltr-w';
        s.setAttribute('aria-hidden', 'true');
        s.textContent = w;
        el.appendChild(s);
        if (i < all.length - 1) el.appendChild(document.createTextNode(' '));
        // A guess keeps the word's punctuation.
        var bare = w.replace(/^[^\w']+|[^\w']+$/g, '');
        var guess = fixes[bare.toLowerCase()];
        return { el: s, word: w, guess: guess ? w.replace(bare, guess) : null };
      });
      var dot = document.createElement('span');
      dot.className = 'ltr-dot';
      dot.setAttribute('aria-hidden', 'true');
      el.insertBefore(dot, el.firstChild);
      el._ltr = { words: words, dot: dot, timers: [], run: 0 };
    }

    function firm(w) {
      if (w.el.classList.contains('ltr-firm')) return;
      if (w.anim) w.anim.pause();
      w.el.style.opacity = '';
      w.el.textContent = w.word;
      w.el.classList.remove('ltr-heard');
      w.el.classList.add('ltr-firm');
      if (w.guess) anime.animate(w.el, { y: [-3, 0], duration: 380, ease: 'outBack(2)' });
    }

    function play(el) {
      var st = el._ltr, d = el.dataset;
      var rate = +d.rate || DEFAULTS.rate;
      var lag = d.lag !== undefined ? +d.lag : DEFAULTS.lag;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var run = ++st.run;
      st.timers.forEach(clearTimeout);
      st.timers = [];
      function later(fn, ms) { st.timers.push(setTimeout(function () { if (run === st.run) fn(); }, ms)); }
      el.classList.remove('ltr-off');
      st.words.forEach(function (w) {
        w.el.className = 'ltr-w';
        w.el.textContent = w.word;
        w.el.style.transform = '';
        w.el.style.opacity = '';
      });
      el.insertBefore(st.dot, el.firstChild);
      // Move the live dot to the end of the heard words as they arrive.
      var t = 300;
      st.words.forEach(function (w, i) {
        // Speech comes in bursts: short words quicker, a breath after punctuation.
        var step = 1000 / rate * (0.55 + Math.min(w.word.length, 9) / 12) * (0.8 + Math.random() * 0.4);
        if (i > 0 && /[.,!?;:]$/.test(st.words[i - 1].word)) step += 260;
        t += step;
        later(function () {
          w.el.textContent = w.guess || w.word;
          w.el.classList.add('ltr-heard');
          w.anim = anime.animate(w.el, { opacity: [0, 0.45], duration: 220, ease: 'out(2)' });
          w.el.parentNode.insertBefore(st.dot, w.el.nextSibling);
          // Words firm up a few behind the newest one, and all at a full stop.
          var upto = /[.!?]$/.test(w.word) ? i : i - lag;
          for (var k = 0; k <= upto; k++) firm(st.words[k]);
        }, t);
      });
      later(function () {
        st.words.forEach(firm);
        el.classList.add('ltr-off');
        el.appendChild(st.dot);
      }, t + 700);
      if (d.loop !== undefined) {
        later(function () {
          anime.animate(st.words.map(function (w) { return w.el; }), {
            opacity: 0, duration: 400, ease: 'in(2)',
            onComplete: function () { if (run === st.run) later(function () { play(el); }, 300); }
          });
        }, t + 700 + hold);
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-live-transcript]').forEach(function (el) {
        if (el._ltr) return;
        setup(el);
        if (reduced()) {
          el._ltr.words.forEach(function (w) { w.el.className = 'ltr-w ltr-firm'; });
          el.classList.add('ltr-off');
        } else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-live-transcript data-fixes="ten=then">
  So the plan is to meet at the station at ten.
</p>