A per-word karaoke wipe driven straight off a ticking time source looks stepped instead of smooth, a word that was already fully sung stays lit after the user seeks backward past it, or skinning an existing line-level lyrics renderer for word-level highlighting leaves an unsynced sheet glowing white end to end. Use when building or debugging word-by-word lyric highlighting, a synced-transcript view, or any left-to-right text "fill" effect driven by a playback clock.
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill word-timed-karaoke-lyrics
A word-level lyric line only stores a start time per word; there is no end time in the data.
The renderer infers it as the next word's start time, falling back to the line's own end time (or a
guessed duration) only for the line's last word:
data class WordTiming(
val text: String,
val startTimeMs: Long,
)
Each word then animates its own "wipe" independently, driven by a wall-clock Animatable rather than
by reading the playback clock's value directly — that split is where most of the traps live. Above
the word layer, a line degrades through three tiers: word-level wipe when per-word timestamps parse,
a flat per-line highlight when only line timestamps exist, uniform dim text when there is no sync
data. Making the tiers look like a graceful downgrade, not three renderers bolted together, is the
other half of this skill.
Reading the playback clock's value straight into the wipe's progress is wrong in two directions.
A time source that only emits every ~100ms produces a progress fraction that visibly steps instead
of gliding. The fix — an Animatable that snaps to the sampled fraction and then animateTo(1f) in
real wall-clock time — introduces the opposite bug: the animatable's raw .value now holds whatever
it last animated to, including after the word stops being "active." Seek backward past a word that
was already fully sung and its animatable is still sitting near 1f; read it raw and the word lights
up before it has started. The fix is to never trust the animatable's value directly — derive the
exposed progress from the discrete state instead, and only feed the animatable on the two edges that
have a definite answer:
// adapted — collapsed from a multi-line `when`, with the surrounding block comment's reasoning
// folded into two inline comments that are not present in the source
val wordProgress = when {
isPast -> 1f
isActive -> progress // the animatable — only trusted here
else -> 0f // future word: always zero, whatever the animatable holds
}
Word duration needs both a floor and a shape, not one linear scale. A word lasting 80ms of song
time animated in 80ms of wall-clock time is imperceptible — clamp the animation to a minimum (e.g.
150ms) and let the *next* word's isPast snap-to-1f catch up the small lag; matching the wipe
exactly to the data makes every short word invisible. Duration also needs the right curve, not a
bigger one: scaling one easing by "how long the word lasts" makes long held notes look flat —
backwards from how singing reads. Use two growth curves gated on duration relative to a reference
length: *cubed* below it (syllables get almost no emphasis), *square root* above it (a held note
plateaus instead of blowing out).
Snapping a per-character effect on or off at each character's boundary reads as flicker. If a
travelling highlight within a word is "character N is lit / character N is not," the light switches
off completely between one glyph and the next instead of handing over. Compute a continuous falloff
from the playhead's fractional position to each character's center, so brightness fades out of one
glyph as it fades into its neighbour.
A glow that should bleed outside a glyph's box must not go through an alpha modifier. Fading a
node's opacity forces it into an offscreen layer sized to the node's own bounds, clipping anything
meant to spill past that box — a bloom around a character gets sliced into a flat rectangle. Keep the
glowing text permanently composed and animate the *glow's own color alpha* instead of node opacity.
A parse failure has to fall back to a line the same shape as its neighbours, not a shorter one.
When per-word parsing fails on a single malformed line, falling back to a plainer, differently-styled
line item renders that one line at a different size than everything around it — worse than the
word-level effect just not firing. Fall back to the same visual shape with highlighting turned off.
Copying a sibling renderer's "treat everything as current" boolean turns a whole page into one glow.
A line-level renderer commonly renders an unsynced sheet with every line "bold" — `isCurrent = index
== currentLineIndex || syncType != "LINE_SYNCED"` — a reasonable default when "current" only toggles
a font weight. Reuse that OR-clause in a differently-skinned renderer where "current" also drives full
brightness, an active glow and zero blur, and every line in an unsynced sheet lights up simultaneously
— there's no "the sung line" without sync data, and the *other* renderer's convenience default said
otherwise. Check what "current" actually triggers before reusing a boolean across renderers.
The check for "no active line yet" must run before the check for "this is the active line." A
dedicated pre-roll treatment (uniform dim, unblurred, before the first cue) has to be evaluated first
in the branch that computes focus per line — read in the other order, every line compares
distance == 0 against a sentinel index, and the *first* line looks active during the intro.
Anchoring the active line to a fixed row offset from the top breaks the moment a line wraps.
"Keep one physical text row of the previous line visible above the active one" is not the same as
scrolling to activeIndex - 1: a wrapped lyric is one list item spanning several rows, so anchoring
the *item* hangs the whole multi-row block above the active line instead of one row of it. Anchor the
active item itself and back off by one row's *height* in pixels, not by one list index — this still
needs the one-frame-wait-then-measure dance any "scroll to and align an item" helper needs; see
lazy-scroll-helper-kit for that half.
**Splitting a string into individual Chars to drive a per-character effect assumes one Char is
one glyph.** It is not, for any text that contains a combining mark or a character outside the Basic
Multilingual Plane — see combining-chars-break-char-literals. A per-character wipe or glow built
on word.toCharArray() is safe for plain Latin lyrics and silently wrong the moment a word contains
a combining accent or vowel sign, which renders as its own, separately-colored cell next to an
incomplete base glyph instead of as part of one character. When the effect doesn't need per-character
timing, text-brush-shimmer-sweep gets a travelling highlight with no per-Char split at all, by
animating a gradient directly on the TextStyle.
**A rich-synced line's raw string still carries its inline per-word timestamps — any secondary
consumer must strip them first, or it processes the markers instead of the words.** The renderer
needs the raw <mm:ss.xx> markers to drive the wipe, but nothing else reading that same string does:
romanization strips them before handing the line to the romanizer (LyricsView.kt:453), and the
share sheet strips every line the same way before flattening it to plain text
(ShareLyricsLines.kt:16). Both call one shared helper, stripRichSyncTimestamps()
(NowPlayingContentState.kt:56: replace each <mm:ss.xx> marker with a space, collapse whitespace,
trim) rather than rolling their own regex — a new reader of a rich-synced line owes it that same call.
Run from the root of the mined repository (adapt file names to your own):
# 1. the timing model stores only a start time; the end is computed downstream, not stored
grep -n "data class WordTiming" -A3 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/RichSyncParser.kt
grep -n "wordEndTimeMs =" composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt
# 2. progress is derived from discrete state, never read raw off the animatable
grep -n "val wordProgress" -A6 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt
# 3. the pre-roll branch is checked before the "this is the active line" branch
grep -n "!hasActiveLine ->" -A3 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AppleMusicLyricsLines.kt
# 4. the two sibling renderers disagree about the OR-clause, on purpose
grep -n "NOT Classic's" -A7 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt
# 5. a parse failure falls back to the SAME-shaped line item, not a smaller one
grep -n "Parsing failed" -A3 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt
Pass condition for 1–2: WordTiming has no end-time field, and wordEndTimeMs is a local val
computed per word, not a property read off the parsed model. For 3: !hasActiveLine is the first
arm of the when. For 4: the comment names the exact clause being deliberately omitted. For 5: the
fallback constructor is the style-matching one (AppleMusicLyricsLineItem), not the plain one.
Then by hand: play a rich-synced line and confirm the wipe never steps; seek backward into a sung
line and confirm no word lights early; open an unsynced song and confirm nothing looks "active."
Take maxrave-dev/word-timed-karaoke-lyrics from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.