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
- Paste the snippet just before
</head>. - Add
data-live-transcriptto a paragraph holding the finished transcript. - List a few misheard words in
data-fixesto have them correct themselves as they firm up.
Options
Add any of these attributes to the same element.
data-fixes | Misheard guesses as word=guess pairs, separated by commas, e.g. ten=then, meet=meat. |
data-rate | Words heard each second. Default 3.2. |
data-lag | How many of the newest words stay unsure. Default 3. Everything firms up at a full stop. |
data-loop | Clear and start again after a pause. |
data-hold | How long the finished transcript shows before looping. Default 2600 ms. |
data-color | Colour of the live dot. Default #ef4444. |
Screen readers get the whole transcript straight away, as the element’s label.
<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><p data-live-transcript data-fixes="ten=then">
So the plan is to meet at the station at ten.
</p>