fix(ui): stabilize lyrics rise animations

- Keep translation lift stable across timing modes, wrapping, and release states.\n- Drive a compact character wave from the shared media timeline with smoother easing.

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-25 15:10:49 +03:00
commit 921807956a
No known key found for this signature in database
5 changed files with 504 additions and 110 deletions

View file

@ -127,10 +127,14 @@ const useStyles = makeStyles((theme) => ({
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
transition: `transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}, background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
},
'&[data-raised="true"][data-line-motion="character"][data-character-wave="false"]':
'&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line':
{
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
},
'&[data-raised="true"][data-line-motion="character"] $translationLine': {
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
},
'&[data-active="true"]': {
'--lyrics-main-current-color':
'var(--lyrics-main-active-color, var(--lyrics-main-idle-color, currentColor))',
@ -143,15 +147,22 @@ const useStyles = makeStyles((theme) => ({
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none',
'&[data-line-motion="character"] $line, &[data-line-motion="character"] $translationLine':
{
transition: 'none',
transform: 'none',
},
},
},
waveCharacter: {
display: 'inline-block',
verticalAlign: 'baseline',
transform: 'translateY(0)',
transform: 'translate3d(0, 0, 0)',
transformOrigin: 'center bottom',
willChange: 'transform',
'@media (prefers-reduced-motion: reduce)': {
transform: 'none !important',
willChange: 'auto',
},
},
line: {
@ -197,14 +208,6 @@ const useStyles = makeStyles((theme) => ({
transform: 'none',
},
},
characterWaveAuxLine: {
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none',
},
},
activeAuxLine: {
opacity: KARAOKE_TRANSLATION_OPACITY,
},
@ -746,7 +749,10 @@ const LyricsPanel = ({
const canSeekLine = Boolean(audioInstance && line.start != null)
const isActiveLine = activeIndexSet.has(idx)
const renderCharacterWave = Boolean(
!prefersReducedMotion && hasTimedMainLines && isActiveLine,
!prefersReducedMotion &&
hasTimedMainLines &&
usesCharacterRise &&
isActiveLine,
)
const isStaticLine = !hasTimedMainLines
return (
@ -882,7 +888,6 @@ const LyricsPanel = ({
line={buildSynchronizedTranslationLine(line, trLine)}
nextLineStart={null}
className={clsx(classes.auxLine, classes.translationLine, {
[classes.characterWaveAuxLine]: renderCharacterWave,
[classes.activeAuxLine]: isStaticLine || isActiveLine,
})}
style={layerStyles.translation}

View file

@ -106,6 +106,7 @@ const createAudio = ({ currentTime = 0, duration = 10 } = {}) => {
describe('<LyricsPanel />', () => {
const originalMatchMedia = window.matchMedia
const originalResizeObserver = window.ResizeObserver
beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
@ -116,6 +117,7 @@ describe('<LyricsPanel />', () => {
vi.useRealTimers()
vi.restoreAllMocks()
window.matchMedia = originalMatchMedia
window.ResizeObserver = originalResizeObserver
cleanup()
})
@ -330,18 +332,33 @@ describe('<LyricsPanel />', () => {
it('raises a line once and keeps it elevated after release', () => {
const { rerenderPanel } = renderPanel({
mainLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'translated line' }],
},
showTranslation: true,
audioInstance: { currentTime: 0.5, paused: true },
})
const group = screen.getByTestId('lyrics-line-group')
const translation = screen
.getByText('translated line')
.closest('[data-tokenized]')
const activeStyle = window.getComputedStyle(group)
expect(group).toHaveAttribute('data-highlight-active', 'true')
expect(group).toHaveAttribute('data-raised', 'true')
expect(group).toHaveAttribute('data-line-motion', 'line')
expect(group).toHaveAttribute('data-character-wave', 'false')
expect(activeStyle.transform).toBe(`translateY(-${KARAOKE_LINE_LIFT_PX}px)`)
expect(window.getComputedStyle(translation).transform).toBe('')
rerenderPanel({
mainLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'translated line' }],
},
showTranslation: true,
audioInstance: { currentTime: 1.1, paused: true },
})
@ -351,24 +368,13 @@ describe('<LyricsPanel />', () => {
expect(releasedStyle.transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
})
it('keeps every word-timed layer on the same rise and release lifecycle', () => {
const offsetPronunciationLyric = {
...tokenizedPronunciationLyric,
cueLine: [
{
...tokenizedPronunciationLyric.cueLine[0],
cue: [
{ start: 100, end: 850, value: 'mein' },
{ start: 850, end: 1000, value: 'lain' },
],
},
],
}
const propsAt = (currentTime) => ({
mainLyric: tokenizedMainLyric,
pronunciationLyric: offsetPronunciationLyric,
pronunciationLyric: tokenizedPronunciationLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'translated line' }],
@ -377,7 +383,7 @@ describe('<LyricsPanel />', () => {
showTranslation: true,
audioInstance: { currentTime, paused: true },
})
const { rerenderPanel } = renderPanel(propsAt(0.25))
const { rerenderPanel } = renderPanel(propsAt(0.15))
const group = screen.getByTestId('lyrics-line-group')
const translation = screen
@ -403,7 +409,10 @@ describe('<LyricsPanel />', () => {
expect(mainCharacters).toHaveLength(4)
expect(pronunciationCharacters).toHaveLength(4)
expect(mainCharacters[0].style.transform).toBe(
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
`translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px, 0)`,
)
expect(window.getComputedStyle(mainCharacters[0]).willChange).toBe(
'transform',
)
expect(mainCharacters[3].style.transform).not.toBe(
mainCharacters[0].style.transform,
@ -422,16 +431,103 @@ describe('<LyricsPanel />', () => {
expect(
group.querySelectorAll('[data-lyrics-character="true"]'),
).toHaveLength(0)
expect(window.getComputedStyle(group).transform).toBe(
expect(window.getComputedStyle(group).transform).toBe('translateY(0)')
const releasedMainRow = screen
.getAllByTestId('lyrics-token')[0]
.closest('[data-tokenized]')
expect(window.getComputedStyle(releasedMainRow).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
const idleText = screen
.getAllByTestId('lyrics-token')[0]
.querySelector('[data-lyrics-wave-text="true"]')
expect(idleText.style.backgroundImage).toContain('linear-gradient')
})
it('keeps the translation raised when a word-timed line wraps or unwraps', () => {
let wrapped = false
const observers = []
window.ResizeObserver = class {
constructor(callback) {
this.callback = callback
this.targets = []
observers.push(this)
}
observe(target) {
this.targets.push(target)
}
unobserve(target) {
this.targets = this.targets.filter((candidate) => candidate !== target)
}
disconnect() {
this.targets = []
}
}
vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(
function getOffsetTop() {
if (!wrapped || this.dataset.stackedToken !== 'true') return 0
const tokens = Array.from(
this.parentElement?.querySelectorAll('[data-stacked-token="true"]') ||
[],
)
return tokens.indexOf(this) > 0 ? 32 : 0
},
)
const notifyResizeObservers = () => {
observers.forEach(({ callback, targets }) => {
callback(targets.map((target) => ({ target })))
})
}
const propsAt = (currentTime) => ({
mainLyric: tokenizedMainLyric,
pronunciationLyric: tokenizedPronunciationLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'translated line' }],
},
showPronunciation: true,
showTranslation: true,
audioInstance: { currentTime, paused: true },
})
const { rerenderPanel } = renderPanel(propsAt(0.25))
const row = screen
.getAllByTestId('lyrics-token')[0]
.closest('[data-wrapped]')
const translation = screen
.getByText('translated line')
.closest('[data-tokenized]')
expect(row).toHaveAttribute('data-wrapped', 'false')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
wrapped = true
act(notifyResizeObservers)
expect(row).toHaveAttribute('data-wrapped', 'true')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
rerenderPanel(propsAt(1.1))
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
wrapped = false
act(notifyResizeObservers)
expect(row).toHaveAttribute('data-wrapped', 'false')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
})
it('keeps detailed grapheme markup on active lines without changing token layout', () => {
const values = ['First word', 'Second word', 'Active word', 'Fourth word']
const lyricFor = (pronunciation = false) => ({

View file

@ -1,14 +1,20 @@
export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
export const KARAOKE_HIGHLIGHT_LEAD_MS = 120
export const KARAOKE_ANIMATION_MS = 220
export const KARAOKE_ANIMATION_MS = 500
export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400
export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200
export const KARAOKE_SCROLL_ANIMATION_MS = 300
export const KARAOKE_LINE_ENTER_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_LINE_LIFT_PX = 1.5
export const KARAOKE_LINE_MOTION_EASING = 'cubic-bezier(0.25, 0.1, 0.25, 1)'
const KARAOKE_EASE_IN_OUT_X1 = 0.77
const KARAOKE_EASE_IN_OUT_Y1 = 0
const KARAOKE_EASE_IN_OUT_X2 = 0.175
const KARAOKE_EASE_IN_OUT_Y2 = 1
export const KARAOKE_LINE_MOTION_EASING = `cubic-bezier(${KARAOKE_EASE_IN_OUT_X1}, ${KARAOKE_EASE_IN_OUT_Y1}, ${KARAOKE_EASE_IN_OUT_X2}, ${KARAOKE_EASE_IN_OUT_Y2})`
export const KARAOKE_CHARACTER_LIFT_PX = 1.5
export const KARAOKE_CHARACTER_PHASE_SPREAD = 0.36
export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
export const KARAOKE_TRANSLATION_OPACITY = 0.62
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
@ -23,7 +29,76 @@ export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34
export const TOKEN_WIPE_EDGE_PCT = 12
export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
export const easeInOut = (value) => {
const clamped = clamp(value, 0, 1)
return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2
const cubicBezierCoordinate = (time, firstControl, secondControl) => {
const inverse = 1 - time
return (
3 * inverse * inverse * time * firstControl +
3 * inverse * time * time * secondControl +
time * time * time
)
}
const cubicBezierSlope = (time, firstControl, secondControl) => {
const inverse = 1 - time
return (
3 * inverse * inverse * firstControl +
6 * inverse * time * (secondControl - firstControl) +
3 * time * time * (1 - secondControl)
)
}
const solveStrongEaseInOutTime = (progress) => {
let time = progress
for (let iteration = 0; iteration < 6; iteration += 1) {
const error =
cubicBezierCoordinate(
time,
KARAOKE_EASE_IN_OUT_X1,
KARAOKE_EASE_IN_OUT_X2,
) - progress
if (Math.abs(error) < 0.000001) return time
const slope = cubicBezierSlope(
time,
KARAOKE_EASE_IN_OUT_X1,
KARAOKE_EASE_IN_OUT_X2,
)
if (Math.abs(slope) < 0.000001) break
time = clamp(time - error / slope, 0, 1)
}
let lower = 0
let upper = 1
for (let iteration = 0; iteration < 12; iteration += 1) {
time = (lower + upper) / 2
if (
cubicBezierCoordinate(
time,
KARAOKE_EASE_IN_OUT_X1,
KARAOKE_EASE_IN_OUT_X2,
) < progress
) {
lower = time
} else {
upper = time
}
}
return time
}
export const easeKaraokeMotion = (value) => {
const progress = clamp(value, 0, 1)
if (progress === 0 || progress === 1) return progress
return cubicBezierCoordinate(
solveStrongEaseInOutTime(progress),
KARAOKE_EASE_IN_OUT_Y1,
KARAOKE_EASE_IN_OUT_Y2,
)
}
export const easeInOut = (value) => {
const progress = clamp(value, 0, 1)
return progress < 0.5
? 2 * progress * progress
: 1 - (-2 * progress + 2) ** 2 / 2
}

View file

@ -8,10 +8,13 @@ import {
} from 'react'
import {
KARAOKE_CHARACTER_LIFT_PX,
KARAOKE_CHARACTER_PHASE_SPREAD,
KARAOKE_CHARACTER_RISE_MS,
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
KARAOKE_CLOCK_DRIFT_RESET_MS,
KARAOKE_HIGHLIGHT_LEAD_MS,
KARAOKE_LINE_RELEASE_MS,
easeKaraokeMotion,
} from './lyricsKaraokeConstants'
import {
buildLyricsTimeline,
@ -42,33 +45,83 @@ const setProgress = (record, value) => {
record.node.style.setProperty('--lyrics-progress', String(next))
}
const smootherStep = (value) =>
value * value * value * (value * (value * 6 - 15) + 10)
const setCharacterTransform = (node, localProgress) => {
const lift = Math.max(
0,
Math.min(
KARAOKE_CHARACTER_LIFT_PX,
KARAOKE_CHARACTER_LIFT_PX * easeKaraokeMotion(localProgress),
),
)
if (lift < 0.00005) {
if (node.style.transform) node.style.removeProperty('transform')
return
}
const nextTransform = `translate3d(0, -${lift.toFixed(4)}px, 0)`
if (node.style.transform !== nextTransform) {
node.style.transform = nextTransform
}
}
const setCharacterLift = (record, progress) => {
const resetCharacterLift = (record) => {
const characters = record.characters || []
if (!characters.length) return
if (record.characterMotionState === 'future') return
characters.forEach(({ node }) => setCharacterTransform(node, 0))
record.characterMotionState = 'future'
}
const settleCharacterLift = (record) => {
const characters = record.characters || []
if (!characters.length) return
if (record.characterMotionState === 'settled') return
characters.forEach(({ node }) => setCharacterTransform(node, 1))
record.characterMotionState = 'settled'
}
const setCharacterLiftAtTime = (record, time) => {
const characters = record.characters || []
if (!characters.length) return
const riseSpan = Math.max(0.001, 1 - KARAOKE_CHARACTER_PHASE_SPREAD)
const current = Number(time)
if (!Number.isFinite(current) || current <= record.characterMotionStart) {
resetCharacterLift(record)
return
}
if (current >= record.characterMotionEnd) {
settleCharacterLift(record)
return
}
characters.forEach(({ node, phase }) => {
const local = Math.max(0, Math.min(1, (progress - phase) / riseSpan))
const lift = Math.max(
characters.forEach(({ node, start }) => {
const localProgress = Math.max(
0,
Math.min(
KARAOKE_CHARACTER_LIFT_PX,
KARAOKE_CHARACTER_LIFT_PX * smootherStep(local),
),
Math.min(1, (current - start) / KARAOKE_CHARACTER_RISE_MS),
)
if (lift < 0.00005) {
if (node.style.transform) node.style.removeProperty('transform')
return
}
const nextTransform = `translateY(-${lift.toFixed(4)}px)`
if (node.style.transform !== nextTransform) {
node.style.transform = nextTransform
}
setCharacterTransform(node, localProgress)
})
record.characterMotionState = 'moving'
}
const getCharacterTiming = (window, characterCount) => {
const start = Number(window?.start)
const duration = Number(window?.end) - Number(window?.start)
const safeStart = Number.isFinite(start) ? start : 0
const safeDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0
const waveSpan =
characterCount <= 1
? 0
: Math.min(
safeDuration * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
)
return {
start: safeStart,
waveSpan,
end: safeStart + waveSpan + KARAOKE_CHARACTER_RISE_MS,
}
}
const setCharacterGradient = (record) => {
@ -171,7 +224,6 @@ const applyTokenState = (record, state, progress = 0) => {
const isPast = state === 'inactive-past'
const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1
setProgress(record, nextProgress)
setCharacterLift(record, nextProgress)
setTokenOpacity(record, 1)
setTokenActiveAlpha(
record,
@ -196,10 +248,13 @@ const setTokenReleasePresentation = (record, progress) => {
record,
activeAlpha + (targetAlpha - activeAlpha) * nextProgress,
)
settleCharacterLift(record)
}
const resetToken = (record, state = 'future') => {
applyTokenState(record, state, 0)
if (state === 'future') resetCharacterLift(record)
else settleCharacterLift(record)
}
const setTokenPresentation = (record, time) => {
@ -209,6 +264,7 @@ const setTokenPresentation = (record, time) => {
if (record.state !== state || state === 'active') {
applyTokenState(record, state, progress)
}
setCharacterLiftAtTime(record, time)
}
const canListenToMedia = (audio) =>
@ -477,12 +533,17 @@ const useLyricsTimeline = ({
? []
: Array.from(node.querySelectorAll('[data-lyrics-character="true"]'))
const characterCount = characterNodes.length
const characterTiming = getCharacterTiming(
descriptor.window,
characterCount,
)
const characters = characterNodes.map((characterNode, index) => ({
node: characterNode,
phase:
start:
characterCount <= 1
? 0
: (index / (characterCount - 1)) * KARAOKE_CHARACTER_PHASE_SPREAD,
? characterTiming.start
: characterTiming.start +
(index / (characterCount - 1)) * characterTiming.waveSpan,
}))
const record = {
key,
@ -492,6 +553,9 @@ const useLyricsTimeline = ({
window: descriptor.window,
presentation: descriptor.presentation,
characters,
characterMotionStart: characterTiming.start,
characterMotionEnd: characterTiming.end,
characterMotionState: null,
progress: null,
opacity: null,
activeAlpha: null,

View file

@ -1,7 +1,13 @@
import { act } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { KARAOKE_CHARACTER_LIFT_PX } from './lyricsKaraokeConstants'
import {
KARAOKE_CHARACTER_LIFT_PX,
KARAOKE_CHARACTER_RISE_MS,
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
KARAOKE_HIGHLIGHT_LEAD_MS,
} from './lyricsKaraokeConstants'
import useLyricsTimeline from './useLyricsTimeline'
const createAudio = ({
@ -41,6 +47,10 @@ const presentation = {
useCrossfade: false,
}
const fullCharacterLift = `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(
4,
)}px, 0)`
const createTokenNode = (text = '') => {
const tokenNode = document.createElement('span')
Array.from(text).forEach((character) => {
@ -209,7 +219,7 @@ describe('useLyricsTimeline', () => {
tokenNode
.querySelectorAll('[data-lyrics-character="true"]')
.forEach((character) =>
expect(character.style.transform).toBe('translateY(-1.5000px)'),
expect(character.style.transform).toBe(fullCharacterLift),
)
})
@ -223,7 +233,7 @@ describe('useLyricsTimeline', () => {
'first',
)
syncNow(result, 1219)
syncNow(result, 1259)
const gradient = tokenNode.style.backgroundImage
const releaseAlpha = Number(
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
@ -232,7 +242,7 @@ describe('useLyricsTimeline', () => {
expect(tokenNode.style.color).toBe('transparent')
expect(tokenNode.style.opacity).toBe('1')
syncNow(result, 1220)
syncNow(result, 1260)
const pastAlpha = Number(
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
@ -247,20 +257,20 @@ describe('useLyricsTimeline', () => {
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
})
it('uses the complete token duration for the character phase wave', () => {
const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
it('completes long-token character waves while highlighting continues', () => {
const audio = createAudio({ currentTime: 0, duration: 6, paused: true })
const longLines = [
{
start: 0,
end: 4000,
tokens: [{ start: 0, end: 4000, value: 'super' }],
end: 5000,
tokens: [{ start: 500, end: 4500, value: 'super' }],
},
]
const { result } = renderTimeline({ audio, sourceLines: longLines })
const tokenNode = registerToken(
result,
'0:long-word',
{ start: 0, end: 4000 },
{ start: 500, end: 4500 },
'super',
)
@ -270,77 +280,138 @@ describe('useLyricsTimeline', () => {
expect(characters[0].style.backgroundImage).toBe(
tokenNode.style.backgroundImage,
)
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
syncNow(result, tokenStart)
characters.forEach((character) =>
expect(character.style.transform).not.toMatch(/^translateY\(\d/),
)
const firstTransforms = []
;[0, 160, 320, 480].forEach((time) => {
syncNow(result, time)
firstTransforms.push(characters[0].style.transform)
})
expect(new Set(firstTransforms).size).toBe(firstTransforms.length)
firstTransforms.forEach((transform) =>
expect(transform).toMatch(/^translateY\(-\d+\.\d{4}px\)$/),
expect(character.style.transform).toBe(''),
)
syncNow(result, 1320)
const offsets = characters.map((character) =>
Math.abs(
Number.parseFloat(
character.style.transform.match(/-?\d+\.\d+/)?.[0] || '0',
),
),
)
offsets.forEach((offset) => expect(offset).toBeGreaterThanOrEqual(0))
expect(offsets.filter((offset) => offset > 0)).toHaveLength(4)
expect(offsets[0]).toBeGreaterThan(offsets[1])
expect(offsets[1]).toBeGreaterThan(offsets[2])
expect(offsets[2]).toBeGreaterThan(offsets[3])
expect(offsets[4]).toBe(0)
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
expect(characters[0].style.transform).not.toBe(fullCharacterLift)
expect(characters[0].style.transform).not.toBe('')
expect(characters[4].style.transform).toBe('')
syncNow(result, 3600)
expect(characters[4].style.transform).not.toBe(
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
expect(characters[0].style.transform).toBe(fullCharacterLift)
expect(characters[4].style.transform).toMatch(
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
)
expect(characters[4].style.transform).not.toBe(fullCharacterLift)
syncNow(result, 3880)
const completeWaveAt =
tokenStart +
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS +
KARAOKE_CHARACTER_RISE_MS
syncNow(result, completeWaveAt)
characters.forEach((character) =>
expect(character.style.transform).toBe(
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
),
expect(character.style.transform).toBe(fullCharacterLift),
)
expect(
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
).toBeCloseTo(
(KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + KARAOKE_CHARACTER_RISE_MS) / 4000,
5,
)
})
it('keeps the normalized wave shape for short token durations', () => {
const audio = createAudio({ currentTime: 0, duration: 1, paused: true })
it('finishes short-token motion after highlighting without dropping the lift', () => {
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
const shortLines = [
{
start: 0,
end: 180,
tokens: [{ start: 0, end: 180, value: 'go' }],
end: 1200,
tokens: [{ start: 500, end: 680, value: 'go' }],
},
]
const { result } = renderTimeline({ audio, sourceLines: shortLines })
const tokenNode = registerToken(
result,
'0:short-word',
{ start: 0, end: 180 },
{ start: 500, end: 680 },
'go',
)
syncNow(result, 0)
const characters = tokenNode.querySelectorAll(
'[data-lyrics-character="true"]',
)
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
const waveSpan = 180 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO
syncNow(result, tokenStart)
expect(characters[0].style.transform).toBe('')
expect(characters[1].style.transform).toBe('')
syncNow(result, 680 - KARAOKE_HIGHLIGHT_LEAD_MS)
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
expect(characters[0].style.transform).not.toBe('')
expect(characters[1].style.transform).not.toBe(
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
expect(characters[1].style.transform).not.toBe(fullCharacterLift)
syncNow(result, tokenStart + waveSpan + KARAOKE_CHARACTER_RISE_MS)
expect(characters[0].style.transform).toBe(fullCharacterLift)
expect(characters[1].style.transform).toBe(fullCharacterLift)
})
it('gives a single character the same fixed rise duration', () => {
const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
const sourceLines = [
{
start: 0,
end: 5000,
tokens: [{ start: 120, end: 4120, value: 'I' }],
},
]
const { result } = renderTimeline({ audio, sourceLines })
const tokenNode = registerToken(
result,
'0:single-character',
{ start: 120, end: 4120 },
'I',
)
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
const tokenStart = 120 - KARAOKE_HIGHLIGHT_LEAD_MS
syncNow(result, tokenStart)
expect(character.style.transform).toBe('')
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
expect(character.style.transform).toMatch(
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
)
expect(character.style.transform).not.toBe(fullCharacterLift)
syncNow(result, tokenStart + 240)
expect(character.style.transform).not.toBe(fullCharacterLift)
syncNow(result, tokenStart + 260)
expect(character.style.transform).toBe(fullCharacterLift)
expect(
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
).toBeCloseTo(KARAOKE_CHARACTER_RISE_MS / 4000, 5)
})
it('forces an unfinished character wave to its raised release state', () => {
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
const sourceLines = [
{
start: 0,
end: 600,
tokens: [{ start: 500, end: 550, value: 'go' }],
},
]
const { result } = renderTimeline({ audio, sourceLines })
const tokenNode = registerToken(
result,
'0:release-wave',
{ start: 500, end: 550 },
'go',
)
const characters = tokenNode.querySelectorAll(
'[data-lyrics-character="true"]',
)
syncNow(result, 60)
expect(characters[1].style.transform).toBe(
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
syncNow(result, 500 - KARAOKE_HIGHLIGHT_LEAD_MS)
expect(characters[1].style.transform).toBe('')
syncNow(result, 600)
expect(tokenNode.dataset.lyricsState).toBe('release')
characters.forEach((character) =>
expect(character.style.transform).toBe(fullCharacterLift),
)
})
@ -382,6 +453,89 @@ describe('useLyricsTimeline', () => {
expect(secondProgress).toBeGreaterThanOrEqual(firstProgress)
})
it('drives character motion from playback-rate-adjusted media time', () => {
let frameCallback = null
let now = 0
window.requestAnimationFrame.mockImplementation((callback) => {
frameCallback = callback
return 1
})
vi.spyOn(performance, 'now').mockImplementation(() => now)
const audio = createAudio({
currentTime: 0.38,
duration: 2,
paused: false,
playbackRate: 2,
})
const sourceLines = [
{
start: 0,
end: 1200,
tokens: [{ start: 500, end: 1000, value: 'go' }],
},
]
const { result } = renderTimeline({ audio, sourceLines })
const tokenNode = registerToken(
result,
'0:playback-rate-wave',
{ start: 500, end: 1000 },
'go',
)
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
expect(character.style.transform).toBe('')
now = KARAOKE_CHARACTER_RISE_MS / 4
act(() => frameCallback())
expect(character.style.transform).toMatch(
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
)
expect(character.style.transform).not.toBe(fullCharacterLift)
})
it('reconstructs settled character motion after visibility recovery', () => {
let visibility = 'visible'
vi.spyOn(window.document, 'visibilityState', 'get').mockImplementation(
() => visibility,
)
const audio = createAudio({
currentTime: 0.38,
duration: 2,
paused: false,
})
const sourceLines = [
{
start: 0,
end: 1200,
tokens: [{ start: 500, end: 1000, value: 'go' }],
},
]
const { result } = renderTimeline({ audio, sourceLines })
const tokenNode = registerToken(
result,
'0:visibility-wave',
{ start: 500, end: 1000 },
'go',
)
const characters = tokenNode.querySelectorAll(
'[data-lyrics-character="true"]',
)
visibility = 'hidden'
act(() => {
window.document.dispatchEvent(new Event('visibilitychange'))
})
audio.currentTime = 0.8
visibility = 'visible'
act(() => {
window.document.dispatchEvent(new Event('visibilitychange'))
})
characters.forEach((character) =>
expect(character.style.transform).toBe(fullCharacterLift),
)
})
it('resets monotonic interpolation after an explicit backward seek', () => {
let frameCallback = null
let now = 0