feat: count down long instrumental breaks with breathing dots

This commit is contained in:
DiheChen committed 2026-11-05 10:58:03 +08:00
1 parent 659096ed60
commit e3b7e4111b
4 files changed
+98 -12

No files matched your search

+51 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildLines, lineIndexAt, parseLrc, parseYrc, withTranslations, wordEmphasis, wordProgress } from './lrc'
import { buildLines, lineIndexAt, parseLrc, parseYrc, withInterludes, withTranslations, wordEmphasis, wordProgress } from './lrc'
describe('parseLrc', () => {
it('解析分、秒和不同精度的小数部分', () => {
@@ -172,6 +172,51 @@ describe('withTranslations', () => {
})
})
describe('withInterludes', () => {
const line = (timeMs: number, text: string) => ({ timeMs, text })
it('7 秒以上的间奏平分成三个点的时间', () => {
const [, gap] = withInterludes([line(1000, '一'), line(2000, ''), line(11_000, '二')])
expect(gap.dots).toEqual([
{ startMs: 2000, durationMs: 3000, text: '' },
{ startMs: 5000, durationMs: 3000, text: '' },
{ startMs: 8000, durationMs: 3000, text: '' },
])
})
it('不足 7 秒的间奏和结尾的空白行没有点', () => {
const lines = withInterludes([line(1000, '一'), line(2000, ''), line(8999, '二'), line(20_000, '')])
expect(lines.filter(({ dots }) => dots)).toEqual([])
})
it('两句歌词之间没有空白行时没有点', () => {
expect(withInterludes([line(1000, '一'), line(20_000, '二')])).toEqual([line(1000, '一'), line(20_000, '二')])
})
it('连续的空白行只有紧挨下一句的一行有点', () => {
const lines = withInterludes([line(9000, ''), line(20_000, '一')])
expect(lines.map(({ timeMs, dots }) => [timeMs, dots?.length])).toEqual([[0, undefined], [9000, 3], [20_000, undefined]])
})
it('恰好 7 秒的间奏有点', () => {
const lines = withInterludes([line(1000, '一'), line(2000, ''), line(9000, '二')])
expect(lines[1].dots).toHaveLength(3)
})
it('前奏足够长时从歌曲开头补一段,太短或已有开头的空白行时不补', () => {
const [intro] = withInterludes([line(7000, '一')])
expect(intro).toMatchObject({ timeMs: 0, text: '' })
expect(intro.dots?.at(-1)).toEqual({ startMs: 14_000 / 3, durationMs: 7000 / 3, text: '' })
expect(withInterludes([line(6999, '一')])).toEqual([line(6999, '一')])
expect(withInterludes([line(0, ''), line(9000, '一')])).toHaveLength(2)
})
it('没有歌词时不补前奏', () => {
expect(withInterludes([])).toEqual([])
expect(withInterludes([line(9000, '')])).toEqual([line(9000, '')])
})
})
describe('buildLines', () => {
it('有逐字歌词时用逐字歌词的行,翻译配到这些行上', () => {
const lines = buildLines('[00:01.00]行级歌词', '[1000,500](1000,500,0)逐(1250,250,0)字', '[ti:标题]\n[00:01.00]Word by word')
@@ -183,6 +228,11 @@ describe('buildLines', () => {
expect(buildLines('[00:01.00]行级歌词')).toEqual([{ timeMs: 1000, text: '行级歌词' }])
})
it('行级歌词的长前奏和长间奏也有点', () => {
const lines = buildLines('[00:10.00]一\n[00:11.00]\n[00:20.00]二')
expect(lines.filter(({ dots }) => dots).map(({ timeMs }) => timeMs)).toEqual([0, 11_000])
})
it('逐字歌词解析不出内容时退回行级歌词', () => {
expect(buildLines('[00:01.00]行级歌词', '{"t":0}')).toEqual([{ timeMs: 1000, text: '行级歌词' }])
})
+17 -1
View File
@@ -9,6 +9,7 @@ export interface LyricLine {
text: string
words?: LyricWord[]
translation?: string
dots?: LyricWord[]
}
const timeTag = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
@@ -17,6 +18,8 @@ const yrcWordTag = /\((\d+),(\d+),\d+\)/g
// 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气:清除高亮再点亮会闪烁。
const interludeGapMs = 3000
const maxTranslationGapMs = 500
const minInterludeMs = 7000
const interludeDots = 3
// 字亮起和声音同时发生时,看起来反而慢半拍。
export const lyricLeadMs = 100
@@ -93,9 +96,22 @@ export function withTranslations(lines: LyricLine[], translations: LyricLine[]):
})
}
// 长间奏用点倒计时:每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。
export function withInterludes(lines: LyricLine[]): LyricLine[] {
const first = lines.find(line => line.text)
const withIntro = first && lines[0].timeMs > 0 && first.timeMs >= minInterludeMs ? [{ timeMs: 0, text: '' }, ...lines] : lines
return withIntro.map((line, i) => {
const next = withIntro[i + 1]
if (line.text || !next?.text || next.timeMs - line.timeMs < minInterludeMs)
return line
const durationMs = (next.timeMs - line.timeMs) / interludeDots
return { ...line, dots: Array.from({ length: interludeDots }, (_, dot) => ({ startMs: line.timeMs + dot * durationMs, durationMs, text: '' })) }
})
}
export function buildLines(lyric: string, words = '', translation = ''): LyricLine[] {
const wordLines = parseYrc(words)
return withTranslations(wordLines.length ? wordLines : parseLrc(lyric), parseLrc(translation))
return withInterludes(withTranslations(wordLines.length ? wordLines : parseLrc(lyric), parseLrc(translation)))
}
export function lineIndexAt(lines: LyricLine[], positionMs: number): number {
+8
View File
@@ -15,9 +15,17 @@
.lyric-line.active-lyric .lyric-word.lyric-sung { transform: translateY(-.05em); }
.lyric-line.active-lyric .lyric-word.lyric-held { transform: translateY(-.1em) scale(calc(1 + var(--lyric-emphasis) * .14)); filter: drop-shadow(0 0 .12em color-mix(in srgb, var(--color-foreground) calc(var(--lyric-emphasis) * 40%), transparent)); }
.lyric-translation { display: block; margin-top: 2px; color: var(--color-foreground-subtle); font-size: .62em; font-weight: 500; }
.lyric-interlude { display: flex; align-items: center; justify-content: center; gap: 12px; height: 56px; opacity: 0; }
.lyric-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--color-foreground); opacity: calc(.2 + var(--lyric-fill, 0) * .8); }
.lyric-interlude.active-lyric .lyric-dot { animation: lyric-breathe 3.2s ease-in-out infinite; }
.lyrics-scroll:has(.empty-state) { display: grid; place-items: center; }
:root[data-performance='low'] .lyric-line { transition: opacity 160ms ease; }
:root[data-performance='low'] .lyric-interlude.active-lyric .lyric-dot { animation: none; }
:root[data-performance='off'] .lyrics-scroll { mask-image: none; }
@media (max-width: 767px) {
.listen-page { padding: 16px; }
}
@keyframes lyric-breathe {
0%, 100% { transform: scale(.9); }
50% { transform: scale(1.2); }
}
+22 -10
View File
@@ -22,7 +22,13 @@ function LyricWordText({ word }: { word: LyricWord }) {
)
}
// 空白行不渲染节点:当前行为空白行时,滚动到前后两行的中间。
// 间奏的点平时不可见,不算相邻的行。
function neighborLine(node: Element, side: 'previousElementSibling' | 'nextElementSibling'): Element | null {
const neighbor = node[side]
return neighbor?.classList.contains('lyric-interlude') ? neighbor[side] : neighbor
}
// 没有点的空白行不渲染节点:当前行为这种空白行时,滚动到前后两行的中间。
function centerLine(container: HTMLElement, nodes: Map<number, HTMLElement>, index: number, smooth: boolean): void {
let before: HTMLElement | undefined
let after: HTMLElement | undefined
@@ -68,14 +74,14 @@ export function Lyrics() {
previous?.classList.remove('active-lyric')
previous?.removeAttribute('aria-current')
const current = nodes.get(index)
const words = lines[index]?.words
const words = lines[index]?.words ?? lines[index]?.dots
activeWords = current && words
? Array.from(current.querySelectorAll<HTMLElement>('.lyric-word'), (node, i) => ({ node, word: words[i] }))
? Array.from(current.querySelectorAll<HTMLElement>('.lyric-word, .lyric-dot'), (node, i) => ({ node, word: words[i] }))
: []
if (current) {
current.classList.add('active-lyric')
current.setAttribute('aria-current', 'true')
near = [current.previousElementSibling, current.nextElementSibling].filter((node): node is Element => node !== null)
near = [neighborLine(current, 'previousElementSibling'), neighborLine(current, 'nextElementSibling')].filter((node): node is Element => node !== null)
}
else {
let before: HTMLElement | undefined
@@ -135,12 +141,18 @@ export function Lyrics() {
<section className="lyrics-panel" aria-label="同步歌词">
<div className="lyrics-scroll" ref={scrollRef}>
{lines.some(line => line.text)
? lines.map((line, index) => line.text && (
<p className="lyric-line text-ui-xl" data-lyric-index={index} key={`${line.timeMs}:${line.text}`}>
{line.words?.map(word => <LyricWordText key={`${word.startMs}:${word.text}`} word={word} />) ?? line.text}
{line.translation && <span className="lyric-translation">{line.translation}</span>}
</p>
))
? lines.map((line, index) => line.dots
? (
<p className="lyric-line lyric-interlude" aria-hidden="true" data-lyric-index={index} key={`${line.timeMs}:`}>
{line.dots.map(dot => <span className="lyric-dot" key={dot.startMs} />)}
</p>
)
: line.text && (
<p className="lyric-line text-ui-xl" data-lyric-index={index} key={`${line.timeMs}:${line.text}`}>
{line.words?.map(word => <LyricWordText key={`${word.startMs}:${word.text}`} word={word} />) ?? line.text}
{line.translation && <span className="lyric-translation">{line.translation}</span>}
</p>
))
: <EmptyState icon={AlignLeft} title="歌词随音乐浮现">播放带有歌词的歌曲时,会在这里同步显示。</EmptyState>}
</div>
</section>