edge-tts won’t give you WordBoundary events until you ask
One keyword argument, and it's keyword-only, and it's not in most of the tutorials — and then two things about ASS that cost me considerably longer than the keyword did.
Faceless Renderer, my little pipeline for turning a topic into a vertical video, burns karaoke captions into the frame — the highlight moving word by word as the narration says them. That needs a timestamp for every word, and edge-tts will give you them. It just won’t give them to you by default.
The default is sentences
Here is the constructor:
def __init__( self, text: str, voice: str = DEFAULT_VOICE, *, rate: str = "+0%", volume: str = "+0%", pitch: str = "+0Hz", boundary: Literal["WordBoundary", "SentenceBoundary"] = "SentenceBoundary", ... ):
boundary defaults to SentenceBoundary. So this:
communicate = edge_tts.Communicate(text, voice) async for chunk in communicate.stream(): if chunk["type"] == "WordBoundary": ... # never runs
…produces perfectly good audio and not a single word timing. No error, no warning, no empty-list exception. Your caption builder just receives nothing and you end up staring at the wrong file.
Two things make this easy to lose an evening to.
It’s keyword-only — it’s after the *, so you can’t stumble into it by passing positional arguments in the right order. And a lot of the example code floating around predates the parameter, back when word boundaries were simply what you got. Copy one of those snippets into a current install and it stops working, silently, in exactly this way.
The fix is the obvious one:
communicate = edge_tts.Communicate( text, voice, boundary="WordBoundary", )
If you’d rather not think about it at all, SubMaker will take the chunks and hand you an SRT:
submaker = edge_tts.SubMaker() with open("out.mp3", "wb") as f: async for chunk in communicate.stream(): if chunk["type"] == "audio": f.write(chunk["data"]) elif chunk["type"] == "WordBoundary": submaker.feed(chunk) open("out.srt", "w", encoding="utf-8").write(submaker.get_srt())
(One SubMaker holds one boundary type. Feed it both and it raises.)
But SRT is a subtitle format, and karaoke isn’t subtitles. For ASS you need the raw chunks, and that’s where the next two problems live.
Problem one: the units are 100-nanosecond ticks
A boundary chunk looks like this:
{"type": "WordBoundary", "offset": 6500000, "duration": 3750000, "text": "Welcome"}
offset and duration are in 100-nanosecond ticks — Windows FILETIME units. Ten million per second. That 6,500,000 is 0.65 seconds.
ASS karaoke wants centiseconds. So:
TICKS_PER_SECOND = 10_000_000 TICKS_PER_CENTISECOND = 100_000
If you assume milliseconds — and milliseconds is the reasonable guess — every number is off by a factor of ten thousand and your first word highlights for just over an hour.
Problem two: \k is cumulative, and words have gaps between them
This is the one that actually cost me the evening, because it doesn’t look broken. It looks nearly right, which is worse.
In ASS, karaoke tags run back to back. {\k50}hello means “highlight hello over the next 50 centiseconds”, and the following \k starts the instant that one ends. There is no absolute positioning. The timings are a chain.
edge-tts boundaries are not a chain. They’re absolute offsets, and speech has pauses in it, so:
offset[i] + duration[i] != offset[i + 1]
Emit one \k per word and you’ve silently deleted every pause. The captions start out synced, then creep ahead of the voice, and by the end of a 70-second video the highlight is most of a sentence in front of the narration. Watch the first five seconds and it looks fine, which is how it shipped twice.
The gaps have to become karaoke tags of their own:
def cs(ticks): return round(ticks / TICKS_PER_CENTISECOND) def karaoke_line(words, line_start_ticks): """words: [{"offset": ticks, "duration": ticks, "text": str}, ...]""" parts = [] cursor = cs(line_start_ticks) for i, w in enumerate(words): start = cs(w["offset"]) end = cs(w["offset"] + w["duration"]) # The pause before a word becomes a silent \k of its own, so the # chain keeps absolute time. It rides on the space between words. gap = rf"{{\k{start - cursor}}}" if start > cursor else "" parts.append(gap + (" " if i else "")) parts.append(rf"{{\k{end - start}}}{w['text']}") cursor = end return "".join(parts)
The space goes in whether or not there’s a gap. Tie it to the gap tag and any two words spoken back to back come out as one — Welcometo.
Don’t forget the lead-in. The first word rarely starts at zero — there’s a beat of silence before the voice comes in — and line_start_ticks is what accounts for it. Pass the line’s actual start and the first gap covers the pre-roll. There’s no space in front of the first word to carry it, so that tag goes out with no text after it: {\k65}{\k37}Welcome. libass counts it like any other, and the line doesn’t open with a stray space.
Note where the rounding happens, too. Each tick position is converted to centiseconds once, and every tag is the difference between two of those. Rounding each duration on its own instead throws away up to a centisecond per word, and across a couple of hundred words that accumulates back into visible drift — the same symptom as the missing gaps, just slower. In a quick test with 250 words it came to 1.7 seconds.
The whole thing
communicate = edge_tts.Communicate(text, voice, boundary="WordBoundary") words = [] with open(audio_path, "wb") as f: async for chunk in communicate.stream(): if chunk["type"] == "audio": f.write(chunk["data"]) elif chunk["type"] == "WordBoundary": words.append(chunk) line = karaoke_line(words, line_start_ticks=0)
Three things, then: ask for words, divide by 100,000, and pay for the silence.
The first one is a documented default doing exactly what it says. The other two are the price of a format that stores durations where your data has positions — and that mismatch, not the API, is where the evening went.