Text to speech with highlighted words: sync word timings
Highlight words as text to speech plays: get word timestamps with the audio, then mark the word whose time range holds the player's current time.

To highlight words while text to speech plays, get word timestamps together with the audio, then have your player code mark the word whose start and end times contain the audio's current playback time. With Sume, timestamps: { "words": true } on POST /v1/tts-1.0/generate returns words[] with start and end seconds for the file it generated.
Sume facts come from the TTS 1.0 and STT 1.0 schemas in the Sume API reference, the OpenAPI document behind the API reference docs, read on 2026-09-27. Browser behavior comes from MDN's pages on `timeupdate`, `currentTime`, and `requestAnimationFrame()`. Sume returns the audio and the timings; the highlighting runs in your own page code.
How do I get word timings with the audio?
Add timestamps to the TTS request; the full request is in Text to speech API. The completed job result then carries monotonic words[] with start and end seconds. Make the call from your server, which keeps the API key, and pass the page only the file URL and the timings.
- In the current code each entry is
{ word, start, end }, in seconds from the start of the file, and the result also holdsaudio_urland the file'sduration_seconds, as in the excerpt below. - Add
segmentation: { "mode": "sentence" }to also get gaplesssegments[], one per sentence, for highlighting the current line.
{
"audio_url": "https://media.sume.com/artifacts/artf_demo/tts.mp3",
"duration_seconds": 1.52,
"words": [
{ "word": "Read", "start": 0.08, "end": 0.34 },
{ "word": "along", "start": 0.34, "end": 0.71 },
{ "word": "with", "start": 0.71, "end": 0.9 },
{ "word": "me.", "start": 0.9, "end": 1.35 }
]
}How do I highlight the current word in the browser?
Render one <span> per entry in words[]. On every tick, read the player's currentTime, the current playback time in seconds, and mark the last word whose start has passed. The sample joins tokens with spaces; adjust that for languages written without them.
timeupdateis the simplest tick, but MDN says it fires between about 4 Hz and 66 Hz depending on system load, so at the slow end a short word can pass between two ticks.- A
requestAnimationFrame()loop runs about once per display refresh, most commonly 60 times a second, and most browsers pause it in background tabs. - Setting
currentTimeseeks, so a click on a word can jump the audio to that word'sstart.
// words: result.words, passed down by your server
const audio = document.querySelector("audio");
const box = document.querySelector("#transcript");
const spans = words.map((w) => {
const span = document.createElement("span");
span.textContent = w.word + " ";
span.onclick = () => { audio.currentTime = w.start; };
box.append(span);
return span;
});
let active = -1, frame = 0;
function highlight() {
const t = audio.currentTime;
let i = active > -1 && t >= words[active].start ? active : -1;
while (i + 1 < words.length && words[i + 1].start <= t) i++;
if (i !== active) {
spans[active]?.classList.remove("current");
spans[i]?.classList.add("current");
active = i;
}
}
function loop() { highlight(); frame = audio.paused ? 0 : requestAnimationFrame(loop); }
audio.addEventListener("play", () => { if (!frame) frame = requestAnimationFrame(loop); });
audio.addEventListener("seeked", highlight);What if the words don't match my text exactly?
Build the spans from words[] itself rather than by splitting your original string, so the list and the timings always line up. If you must keep your own markup, walk both lists in order and pair the tokens one by one instead of looking words up by text, since the same word can appear many times.
The timings belong to the generated file. If you edit the audio afterwards, for example by adding an intro, shift every start and end by the same offset.
Can I highlight narration I didn't generate?
Yes, with speech-to-text. POST /v1/stt-1.0/transcribe takes a public HTTPS audio_url and always returns word timings, so there is no flag to set. Each entry has word, start, and end in seconds, plus a type such as word or spacing when one is supplied; skip the spacing entries. Speech-to-text API with word timestamps covers the call.
What does it cost?
Each job bills at its published rate, plus a 5.5% agent fee by default. TTS is priced per transcript character, and in the current code the estimate counts characters only, so asking for word timings adds nothing to it.
| You have | Call | Rate |
|---|---|---|
| Text | POST /v1/tts-1.0/generate with timestamps: { "words": true } | $0.0475 per 1,000 characters; up to 20,000 characters per request |
| Audio | POST /v1/stt-1.0/transcribe | $0.01 per audio minute |
Sources
Related posts
More in Developers
- Webhook URL rejected as invalid? Sume's webhook URL rules
Sume answers 400 invalid_request when a webhook URL is not public HTTPS. The rules for scheme, host, port, and credentials, and the check at delivery.
- Connect Claude Code, Cursor, or Codex to Sume with hosted MCP
Sume's hosted MCP server at mcp.sume.com/mcp lets coding agents generate images, video, audio, and avatars. Setup, OAuth scopes, and spend gates.
- Idempotency keys for AI video APIs: retry without paying twice
An idempotency key makes a retried create return the original run or job instead of a second paid one. How Sume's Idempotency-Key works on each API.
- Signed webhooks for Sume video runs: events, retries, verification
Sume sends one HMAC-SHA256 signed POST when a Format, Action, or Agent Completion run completes or fails. Verify the raw body and dedupe on request_id.
Written by Sume