Skip to content

feat(lyrics): progressive fill on the word being sung - #492

Merged
InstaZDLL merged 5 commits into
mainfrom
feat/491-progressive-karaoke-words
Aug 8, 2026
Merged

feat(lyrics): progressive fill on the word being sung#492
InstaZDLL merged 5 commits into
mainfrom
feat/491-progressive-karaoke-words

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #491.

Word-by-word karaoke already worked — what it did was switch states at each word boundary. This sweeps inside the word instead, so the highlight advances continuously as the word is sung.

How it renders

Two stacked copies of each active word: the base layer carries the accessible text at unsung opacity, and an aria-hidden overlay (the sung state) is clipped to a --kw-fill percentage.

  • The overlay is aria-hidden because the base layer already carries the text — without it, a screen reader would announce every active word twice.
  • Opacity sits on the two layers, never on their shared parent: a parent's opacity applies to its whole subtree, so dimming the box would dim the overlay along with the text it's meant to brighten. My first cut got this wrong.

The two things that decide whether it feels right

1. The position only arrives at 4 Hz. The decoder throttles player:position to one event per 250 ms (POSITION_EMIT_INTERVAL). Painting straight from positionMs would advance the fill in 250 ms steps — visibly worse than the discrete highlight it replaces. Each event is instead an anchor (position + the performance.now() when it landed) and every frame extrapolates from it:

  • scaled by playbackSpeed, so the sweep still tracks at 0.5× and 2×;
  • frozen while paused, so it doesn't drift to the end of the word;
  • clamped at both ends.

Arithmetic verified against a table of boundary cases: start, mid-word, exact end, past the end, before the word, paused, both speed extremes, and a clock that appears to run backwards.

2. Nothing goes through React state. useTrackLyrics is shared by the immersive column and the side panel, and the column renders every line — a setState per frame would re-render both trees ~60×/s. useKaraokeWordFill writes the CSS variable straight onto the one element it owns. The ref is attached to the active word only; moving it is what starts the next sweep.

Fallbacks

All landing on the existing discrete highlight, unchanged:

  • prefers-reduced-motion;
  • a word with no forward-going endMs — the last word of a track keeps -1, and sloppy sources can stamp two words at the same millisecond (no division by zero);
  • lines with no word stamps at all;
  • the side LyricsPanel, which deliberately keeps the cheap version — far smaller surface, and the issue left the call open.

Two bugs fixed before this was pushed

Both found by re-reading the code, not by running it:

  • The loop stopped once the word was full — but a seek backwards inside the same word changes neither the bounds nor isPlaying, so the fill would have stayed pinned at 100 % until the active word changed. It now loops while playback runs.
  • positionMs is a dependency of the loop effect. While paused no loop runs, and the anchor effect only mutates a ref, so a seek would not have repainted at all.

Checks

  • bun run typecheck ✅ · bun run lint ✅ · bun run build
  • The compiled CSS keeps clip-path: inset(0 calc(100% - var(--kw-fill,0%)) 0 0) intact through Lightning CSS, default value included — checked explicitly, since this repo has had a CSS regression that only appeared in a release build.
  • Both consumers (ImmersiveView, ImmersiveSidePanel) mount under PlayerProvider, which the new hook requires; the mini-player webview renders no lyrics.

Not verified

The visual result. This is an animation and I can't watch it run — smoothness, how legible the sweep is against each theme, and whether the unsung/sung contrast reads well are all open. Worth a pass on a track with word-level lyrics before merge.

Also left out on purpose: the optional extras the issue listed under "decide how far to go" (glow on the active word, vertical spring per line, blur on inactive lines). The fill was the substance; those are cosmetic and are better judged by eye than specified blind.

Note

Right-to-left lyrics fill from the wrong edge — the clip reveals left-to-right. The word-level highlight already had that limitation, so this is not a regression; it deserves its own issue rather than a half-fix here.

Summary by CodeRabbit

  • Nouvelles fonctionnalités
    • Les paroles karaoké Enhanced LRC/TTML affichent désormais un remplissage progressif du mot en cours, synchronisé avec la lecture.
    • L’animation fonctionne dans le panneau de paroles et la vue immersive, y compris avec les changements de position et la vitesse de lecture.
  • Accessibilité
    • Une surbrillance simplifiée est utilisée lorsque la réduction des mouvements est activée ou que la durée du mot ne peut pas être déterminée.
    • L’animation est suspendue pendant la pause.

Word-by-word karaoke already worked; what it did was switch states at
each word boundary. This sweeps inside the word instead, which is what
the Apple Music look actually is (issue #491, idea from discussion #488).

Two things decide whether this feels right or broken.

player:position is throttled to 4 Hz by the decoder, so painting from
positionMs would advance the fill in 250 ms steps -- visibly worse than
the discrete highlight it replaces. Each event is treated as an anchor
(position + performance.now() when it landed) and every frame
extrapolates from it, scaled by playbackSpeed so the sweep still tracks
at 0.5x/2x, and frozen while paused so it doesn't drift to the end of
the word. Arithmetic checked against a table of boundary cases (start,
mid, exact end, past end, before the word, paused, both speeds, and a
clock that appears to go backwards).

And nothing goes through React state: useTrackLyrics is shared with the
side panel and the column renders every line, so a setState per frame
would re-render both trees ~60x/s. The loop writes a CSS variable
straight onto the element it owns; the ref is attached to the active
word only, and moving it is what starts the next sweep.

The sung layer is aria-hidden -- the base layer already carries the
text, and without it a screen reader would announce each active word
twice. Opacity sits on the two layers rather than their parent, since a
parent's opacity applies to its whole subtree and would dim the overlay
along with the text it's supposed to brighten.

Falls back to the discrete highlight under prefers-reduced-motion, for a
word with no forward-going endMs (last word of a track, zero-duration
stamps), and in the side panel, which keeps the cheap version.

Refs #491.

Claude-Session: https://claude.ai/code/session_01N9WXrurarkoiT2jgimvyj1
Two gaps in the first cut, both found by re-reading it rather than by
running it.

The loop stopped once the word was full, but a seek backwards *inside*
the same word changes neither the word bounds nor isPlaying -- so the
fill would stay pinned at 100 % until the active word changed. It now
keeps looping while playback runs; one callback writing one string per
frame is cheaper than that bug.

And positionMs is now a dependency of the loop effect. While paused no
loop is running, and the anchor effect only mutates a ref, so a seek
would not have repainted at all. It changes at 4 Hz, so re-running the
effect on it is cheap.

Refs #491.

Claude-Session: https://claude.ai/code/session_01N9WXrurarkoiT2jgimvyj1
Name the effect by what it does — a continuous sweep across the word
being sung — instead of by another player. Comment and prose only, no
behaviour change.

Claude-Session: https://claude.ai/code/session_01N9WXrurarkoiT2jgimvyj1
@InstaZDLL InstaZDLL added scope: frontend React/Vite frontend (src/) scope: docs Docs, README, assets type: feat New feature size: l 200-500 lines labels Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf5ef78c-a790-4f5f-9c05-2ebc1b3c142b

📥 Commits

Reviewing files that changed from the base of the PR and between 15de90c and 23045ab.

📒 Files selected for processing (1)
  • src/hooks/useKaraokeWordFill.ts
📝 Walkthrough

Walkthrough

Le changement ajoute le remplissage progressif des mots karaoke dans la vue immersive. Un hook utilise requestAnimationFrame et met à jour --kw-fill directement dans le DOM. Les mouvements réduits, les durées invalides et le panneau latéral conservent un rendu discret.

Changes

Remplissage progressif des mots karaoke

Layer / File(s) Summary
Calcul et animation du remplissage
src/hooks/useKaraokeWordFill.ts
Le hook valide la durée du mot actif, extrapole la position entre les événements player:position, suspend l’animation en pause et nettoie la boucle lors des changements.
Rendu immersif et replis
src/components/player/ImmersiveLyricsColumn.tsx, src/app.css, docs/features/integrations.md, CLAUDE.md
La vue immersive ajoute une couche de remplissage accessible et applique les états passé, actif et futur. Les styles révèlent la couche avec --kw-fill. La documentation décrit les replis existants.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlaybackPosition
  participant useKaraokeWordFill
  participant ImmersiveLyricsColumn
  participant WordFillLayer
  PlaybackPosition->>useKaraokeWordFill: player:position et playbackSpeed
  useKaraokeWordFill->>useKaraokeWordFill: extrapole la position avec requestAnimationFrame
  useKaraokeWordFill->>WordFillLayer: écrit --kw-fill
  ImmersiveLyricsColumn->>WordFillLayer: rend le texte et la surcouche active
Loading

Possibly related PRs

  • InstaZDLL/WaveFlow#333 : concerne la vue immersive modifiée ici pour ajouter le remplissage progressif des mots.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre décrit clairement l’ajout principal et respecte le format Conventional Commits avec le scope lyrics.
Description check ✅ Passed La description couvre le fonctionnement, les tests, les replis, les limites et le lien avec l’issue, malgré l’absence de certains en-têtes du modèle.
Linked Issues check ✅ Passed Les objectifs codés de l’issue [#491] sont couverts dans la vue immersive, avec interpolation, animation directe, replis et respect de prefers-reduced-motion.
Out of Scope Changes check ✅ Passed Les changements restent liés à [#491] et les mises à jour de documentation correspondent au modèle de travail transversal du dépôt.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/491-progressive-karaoke-words

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useKaraokeWordFill.ts`:
- Around line 49-54: Update the anchor-resetting useEffect in useKaraokeWordFill
so it also depends on isPlaying, causing anchorRef.current to be reinitialized
when playback resumes while preserving the existing positionMs behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 670cf99a-2d7f-4fd6-8e7a-e78be0350a2d

📥 Commits

Reviewing files that changed from the base of the PR and between 3862dc0 and 10be9ed.

📒 Files selected for processing (5)
  • CLAUDE.md
  • docs/features/integrations.md
  • src/app.css
  • src/components/player/ImmersiveLyricsColumn.tsx
  • src/hooks/useKaraokeWordFill.ts

Comment thread src/hooks/useKaraokeWordFill.ts Outdated
The backend stops emitting player:position while paused, so the anchor's
timestamp stayed dated from before the pause. On resume the loop measured
the whole pause as elapsed playback: a 30 s pause mid-word slammed the
fill from 50 % to 100 % until the next position event landed (≤ 250 ms) —
a visible flash on every resume. Reproduced with the same arithmetic
harness used for the boundary cases before fixing.

Re-anchoring on isPlaying resets `at` to now, so elapsed restarts at zero
with the position unchanged.

Refs #491.

Claude-Session: https://claude.ai/code/session_01N9WXrurarkoiT2jgimvyj1

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/hooks/useKaraokeWordFill.ts (2)

61-67: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Rejeter les horodatages non finis.

src/lib/tauri/lyrics.ts documente endMs = +∞ pour le dernier mot. La condition end > start accepte Infinity. Pour une position finie, la ligne [87] produit alors un ratio égal à 0, donc --kw-fill reste à 0% au lieu d’utiliser le repli discret prévu pour les timings finaux.

Validez la finitude des deux bornes avant d’activer l’animation.

Correction proposée
-  const hasSpan = start >= 0 && end > start;
+  const hasSpan =
+    Number.isFinite(start) &&
+    Number.isFinite(end) &&
+    start >= 0 &&
+    end > start;

Ce constat s’appuie sur le contrat LyricsWord de src/lib/tauri/lyrics.ts et sur l’objectif de repli pour les timings finaux.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useKaraokeWordFill.ts` around lines 61 - 67, Update the hasSpan
calculation in the karaoke word timing logic to require both start and end
timestamps to be finite, in addition to start being nonnegative and end being
greater than start. This must reject Infinity and preserve the discrete fallback
for final-word timings.

90-95: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Arrêter la boucle lorsque le mot est entièrement rempli.

positionMs est déjà une dépendance à la ligne [109]. Une recherche arrière dans le même mot relance donc l’effet, même si start, end et isPlaying restent inchangés. La boucle n’a pas besoin de continuer lorsque clamped vaut 1. Elle écrit alors la même valeur à chaque frame pendant les intervalles avant la ligne suivante.

Planifiez la frame suivante uniquement si isPlaying && clamped < 1.

Correction proposée
-      if (isPlaying) raf = requestAnimationFrame(paint);
+      if (isPlaying && clamped < 1) {
+        raf = requestAnimationFrame(paint);
+      }

Ce constat s’appuie sur la dépendance positionMs déclarée à la ligne [109] et sur l’objectif de limiter le coût par frame.

Also applies to: 105-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useKaraokeWordFill.ts` around lines 90 - 95, Update the
requestAnimationFrame scheduling in the paint loop of useKaraokeWordFill so the
next frame is requested only when isPlaying is true and clamped is less than 1.
Preserve the existing fill calculation and dependency behavior, including
restarting correctly after a backward seek.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useKaraokeWordFill.ts`:
- Around line 49-59: Update the anchor management in useKaraokeWordFill to store
the anchor’s playbackSpeed and extrapolate positionMs using the previous speed
before replacing the anchor. Preserve the effective extrapolated position across
speed changes and pauses; when isPlaying resumes, reset only the anchor
timestamp to exclude paused time, without re-anchoring to the potentially stale
event position. Do not simply add playbackSpeed as an effect dependency, and
keep the fill calculation aligned with the updated anchor state.

---

Outside diff comments:
In `@src/hooks/useKaraokeWordFill.ts`:
- Around line 61-67: Update the hasSpan calculation in the karaoke word timing
logic to require both start and end timestamps to be finite, in addition to
start being nonnegative and end being greater than start. This must reject
Infinity and preserve the discrete fallback for final-word timings.
- Around line 90-95: Update the requestAnimationFrame scheduling in the paint
loop of useKaraokeWordFill so the next frame is requested only when isPlaying is
true and clamped is less than 1. Preserve the existing fill calculation and
dependency behavior, including restarting correctly after a backward seek.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c7b46dc1-a383-4e81-8f70-e31aea67b486

📥 Commits

Reviewing files that changed from the base of the PR and between 10be9ed and 15de90c.

📒 Files selected for processing (1)
  • src/hooks/useKaraokeWordFill.ts

Comment thread src/hooks/useKaraokeWordFill.ts Outdated
Three review findings, all confirmed.

The anchor now stores the speed in force from the moment it was taken,
and paint() uses that rather than the live value. Between a speed change
and the next position event (up to 250 ms) the old code applied the NEW
speed to time that was played at the old one -- on a 500 ms word a 1x->2x
switch mis-stated the fill by up to half its width.

The anchor also carries the extrapolated position over when the trigger
was a speed change or a play/pause, instead of snapping back to the last
event's position. That was a visible backwards jump on every pause: the
last emitted position can be up to 250 ms behind where the fill had
actually reached. A resume still excludes the paused time, because `at`
is reset -- which is what the previous commit was reaching for, done
without discarding progress.

hasSpan now requires finite bounds. `LyricsWord`'s contract allows +∞ for
a final word, which passes `end > start` while making every ratio 0, so
the word would silently never light up instead of falling back to the
discrete highlight.

The loop stops again at a full word. It was made unconditional to survive
a backward seek inside the same word, but positionMs became a dependency
of the effect since, so that case re-runs the effect and repaints -- and
stopping saves holding a frame callback through a long word's tail.

Replayed against the arithmetic harness: continuity across a 1x->2x
switch, pause/resume after 30 s, a backend event overriding drift, and a
backward seek. The original boundary table still passes.

Refs #491.

Claude-Session: https://claude.ai/code/session_01N9WXrurarkoiT2jgimvyj1
@InstaZDLL InstaZDLL self-assigned this Aug 8, 2026
@InstaZDLL
InstaZDLL merged commit 8965f09 into main Aug 8, 2026
14 checks passed
@InstaZDLL
InstaZDLL deleted the feat/491-progressive-karaoke-words branch August 8, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: docs Docs, README, assets scope: frontend React/Vite frontend (src/) size: l 200-500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: progressive fill on the word being sung (word-level karaoke)

1 participant