diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx
index bc6cfe20d..8a73dda21 100644
--- a/ui/src/audioplayer/LyricsPanel.jsx
+++ b/ui/src/audioplayer/LyricsPanel.jsx
@@ -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}
diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx
index 40bd9ea9b..032e369e3 100644
--- a/ui/src/audioplayer/LyricsPanel.test.jsx
+++ b/ui/src/audioplayer/LyricsPanel.test.jsx
@@ -106,6 +106,7 @@ const createAudio = ({ currentTime = 0, duration = 10 } = {}) => {
describe('', () => {
const originalMatchMedia = window.matchMedia
+ const originalResizeObserver = window.ResizeObserver
beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
@@ -116,6 +117,7 @@ describe('', () => {
vi.useRealTimers()
vi.restoreAllMocks()
window.matchMedia = originalMatchMedia
+ window.ResizeObserver = originalResizeObserver
cleanup()
})
@@ -330,18 +332,33 @@ describe('', () => {
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('', () => {
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('', () => {
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('', () => {
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('', () => {
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) => ({
diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js
index db48b7bd9..bce62f151 100644
--- a/ui/src/audioplayer/lyricsKaraokeConstants.js
+++ b/ui/src/audioplayer/lyricsKaraokeConstants.js
@@ -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
}
diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js
index b83aa4b0d..508da1069 100644
--- a/ui/src/audioplayer/useLyricsTimeline.js
+++ b/ui/src/audioplayer/useLyricsTimeline.js
@@ -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,
diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js
index 84f45918c..4c39a45fb 100644
--- a/ui/src/audioplayer/useLyricsTimeline.test.js
+++ b/ui/src/audioplayer/useLyricsTimeline.test.js
@@ -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