前言
不知道大家在使用音乐播放器时,有没有好奇,音乐歌词聚焦的实现,如何实现歌曲和歌词的对应,如何实现歌词的逐字显示的,作为一个热衷探索的人,今天来好好探究下,看看如何实现这些效果。
说明
一般来说歌词的标准文件格式为lrc, 国内主流的音乐平台分为 QQ音乐的 qrc 和 网易云的 wrc, 我们今天以标准开发的 lrc 来做一下分析。
我们先随便找一个lrc 文件看看
[00:00.000] 作词 : 许嵩
[00:01.000] 作曲 : 许嵩
[00:19.00]
[00:19.32]儿时凿壁偷了谁家的光
[00:23.32]宿昔不梳 一苦十年寒窗
[00:26.90]如今灯下闲读 红袖添香
[00:31.63]半生浮名只是虚妄
[00:35.23]三月 一路烟霞 莺飞草长
[00:38.44]柳絮纷飞里看见了故乡
这样一看,好像很简单,我们只需要解析下时间,然后动态匹配下当前时间和行数,就可以知道当时展示什么歌词了,开干。
逐行歌词
第一步 分析歌词格式
我们可以看到歌词的格式是开头是 [00:00:00] 用来表示当前歌词行的聚焦时间,我们需要做的就是格式化当前的文件,处理为我们方便解析的对象
const formatLrcList = (lrc) => {
// 第一步我们先把数据拆分为数组
let lrcList = lrc.replace(/([^\]^\n])\[/g, (_, p1) => p1 + '\n[').split('\n');
// 然后我们把时间和数字进行拆分
lrcList = lrcList.map(item => {
const lrcItme = {};
lrcItme.time = item.match(/\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/g)[0];
lrcItme.text = item.replace(lrcItme.time, '')
lrcItme.source = item
return lrcItme
})
return lrcList
}
这时我们发现我们可以得到这样的数据了
{"time":"[00:00.000]","text":" 作词 : 许嵩","source":"[00:00.000] 作词 : 许嵩"}
{"time":"[00:01.000]","text":" 作曲 : 许嵩","source":"[00:01.000] 作曲 : 许嵩"}
`
{"time":"[00:19.00]","text":"","source":"[00:19.00]"}
在线例子
第二步 时间的解析
我们已经把数据处理成我们想要的样子了,但是时间好像还没办法直接读取,那么我们来新增一个时间格式化的方法,把时间处理为我们想要的格式
// 我们需要把时间 [00:01:000] 这样的格式处理为 1000ms 这样的数据
const formtTime = (str) => {
let mSec = 2;
let time = 0;
// 第一位是分,第二位是秒,第三位是毫秒
const timeArr = /\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/.exec(str)?.map((item, index) => {
// 因为毫秒分为两位数和三位数,所以这里当匹配到第四位,也就是真正的毫秒需要判断当前文件的毫秒是哪种情况
if (index === 4) {
mSec = item?.length ?? mSec;
}
return Number(item)
});
time = timeArr[1] * 60 + timeArr[2] + (timeArr[4] / (mSec.length === 2 ? 100 : 1000) ?? 0)
return time
}
这样我们就得到了完整的时间和文字的匹配
{"time":0,"text":" 作词 : 许嵩","source":"[00:00.000] 作词 : 许嵩"}
{"time":1,"text":" 作曲 : 许嵩","source":"[00:01.000] 作曲 : 许嵩"}
{"time":19,"text":"","source":"[00:19.00]"}
第三步 让歌词根据时间对应起来
这里我们新增一个计时器,来模拟下歌曲时间的流逝和歌词的展示情况
const list = formatLrcList(lrcStr)
const dom = document.querySelector('.lrc-list')
list.forEach(item => {
const p = document.createElement('p')
p.innerText = `${item.text} - ${item.time}s`
dom.appendChild(p)
})
let time = ~~(Math.random() * 20000) + 20000
const timeDom = document.querySelector('.time')
const curDom = document.querySelector('.cur-lrc')
// 这里是歌曲最大时间,我们假设超过就会直接重置掉时间
const maxTime = list[list.length - 1].time
setInterval(() => {
if (time > maxTime * 1000) time = 0
time = time + 16
}, 16)
const loop = () => {
timeDom.textContent = `当前时间: ${time} ms`
// 判断当前展示哪一行歌词
// 判断逻辑为,如果当前时间小于下一个,那么聚焦上一行
let i = 0;
while (i < list.length) {
if (time > list[i].time * 1000) {
i++
} else {
break
}
}
curDom.textContent = `${list[i - 1]?.text} - ${list[i - 1].time}`
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
效果如下, 我们可以看到红色的字会显示当前应该展示的歌词
第四步歌曲联动
上面我们已经实现了时间和歌曲的联动,那么接下来,我们只需要接入音频播放器,然后完成时间的模拟,那么就实现了歌曲和歌词的联动,开整。
// 添加对应的 audio 标签
<audio controls loop id="audio">
<source src="https://cdn.jsdelivr.net/gh/xyxiao001/music-lib@0.0.2/%E8%AE%B8%E5%B5%A9%20-%20%E5%BA%90%E5%B7%9E%E6%9C%88.min.mp3" type="audio/mpeg">
您的浏览器不支持 audio 元素。
</audio>
// 实时读取一下时间
// 真实的音乐播放映射
const audio = document.querySelector('#audio')
time = audio?.currentTime ?? 0;
这样我们就实现了一个简易的歌曲播放展示歌词的功能,如果真实开发,可以和 audio 标签的各种事件做关联来完成联动。
逐字歌词
我们上面部分,只实现了歌词到行的解析,并没有实现我们最开始看到的那张图,能把时间精确到字,那我们都不能做到边听边唱,那接下来就开始升级版的解析,看看如何实现。
解析
这时就需要先介绍下lrc增强版了,格式如下
[00:00.000]庐[00:00.638]州[00:01.276]月[00:01.914] [00:02.552]-[00:03.190] [00:03.828]许[00:04.466]嵩[00:05.104]
[00:05.110]词[00:06.387]:[00:07.664]许[00:08.941]嵩[00:10.218]
[00:10.220]曲[00:11.497]:[00:12.774]许[00:14.051]嵩[00:15.328]
[00:15.330]编[00:16.352]曲[00:17.374]:[00:18.396]许[00:19.418]嵩[00:20.440]
[00:20.444]儿[00:20.683]时[00:20.980]凿[00:21.292]壁[00:21.580]偷[00:21.884]了[00:22.175]谁[00:22.476]家[00:22.772]的[00:23.123]光[00:23.620]
[00:24.115]宿[00:24.428]昔[00:24.764]不[00:25.099]梳[00:25.421] [00:25.421]一[00:25.716]苦[00:26.060]十[00:26.373]年[00:26.668]寒[00:26.988]窗[00:27.519]
我们观察数据发现,这个格式,可以拿到每个字的开始时间和结束时间,那如果能拿到每行的时间和每个字的时间,我们就可以实现歌曲和字的对应了,开写
第一步 熟悉的分析歌词格式
首先还是数据格式拆分,我们需要把数据格式化成我们想要的数据,按行进行数据拆分
const formatLrcList = (lrc) => {
// 第一步我们先把数据拆分为数组
let lrcList = lrc.split('\n');
// 然后我们把时间和数字进行拆分
lrcList = lrcList.map(item => {
const lrcItme = {}
// 把这一行所有的时间提取出来
lrcItme.time = item.match(/\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/g)
// 把这一行所有的文字提取出来
lrcItme.text = item.match(/(?<=\])\S\s?(?=\[)/g)
lrcItme.source = item
return lrcItme
})
return lrcList
}
这样我们得到了如下数据
{"time":["[00:00.000]","[00:00.638]","[00:01.276]","[00:01.914]","[00:02.552]","[00:03.190]","[00:03.828]","[00:04.466]","[00:05.104]"],"text":["庐","州","月","-","许","嵩"],"source":"[00:00.000]庐[00:00.638]州[00:01.276]月[00:01.914] [00:02.552]-[00:03.190] [00:03.828]许[00:04.466]嵩[00:05.104]"}
{"time":["[00:05.110]","[00:06.387]","[00:07.664]","[00:08.941]","[00:10.218]"],"text":["词",":","许","嵩"],"source":"[00:05.110]词[00:06.387]:[00:07.664]许[00:08.941]嵩[00:10.218]"}
第二步 时间的解析
我们发现这样的数据,我们还是不算很方便,我们对数据进行处理下,规整下数据格式,可以很方便的拿到一行的开始时间和结束时间,还有每个字的开始时间和结束时间
const formatLrcList = (lrc) => {
// 第一步我们先把数据拆分为数组
let lrcList = lrc.split('\n');
// 然后我们把时间和数字进行拆分
lrcList = lrcList.map(item => {
const lrcItme = {}
const times = item.match(/\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/g)
const text = item.match(/(?<=\])\S\s?(?=\[)/g)
lrcItme.starTime = formtTime(times[0])
lrcItme.endTime = formtTime(times[times.length - 1])
lrcItme.text = text.join('')
lrcItme.source = item
lrcItme.children = text.map((key, index) => {
return {
text: key,
starTime: formtTime(times[index]),
endTime: formtTime(times[index + 1]),
}
})
return lrcItme
})
return lrcList
}
const formtTime = (str) => {
let mSec = 2;
let time = 0;
// 第一位是分,第二位是秒,第三位是毫秒
const timeArr = /\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/.exec(str)?.map((item, index) => {
// 因为毫秒分为两位数和三位数,所以这里当匹配到第四位,也就是真正的毫秒需要判断当前文件的毫秒是那种情况
if (index === 4) {
mSec = item?.length ?? mSec;
}
return Number(item)
});
time = timeArr[1] * 60 + timeArr[2] + (timeArr[4] / (mSec.length === 2 ? 100 : 1000) ?? 0)
return Number(time.toFixed(3))
}
co
经过对每行和每个字的处理匹配,我们得到了如下数据,可以看到, 每一行的开始时间和结束时间我们都拿到了,每个字的开始时间和结束时间我们也拿到了,可以开始做动态匹配了。
{"starTime":0,"endTime":5.104,"text":"庐州月-许嵩","source":"[00:00.000]庐[00:00.638]州[00:01.276]月[00:01.914] [00:02.552]-[00:03.190] [00:03.828]许[00:04.466]嵩[00:05.104]","children":[{"text":"庐","starTime":0,"endTime":0.638},{"text":"州","starTime":0.638,"endTime":1.276},{"text":"月","starTime":1.276,"endTime":1.914},{"text":"-","starTime":1.914,"endTime":2.552},{"text":"许","starTime":2.552,"endTime":3.19},{"text":"嵩","starTime":3.19,"endTime":3.828}]}
{"starTime":5.11,"endTime":10.218,"text":"词:许嵩","source":"[00:05.110]词[00:06.387]:[00:07.664]许[00:08.941]嵩[00:10.218]","children":[{"text":"词","starTime":5.11,"endTime":6.387},{"text":":","starTime":6.387,"endTime":7.664},{"text":"许","starTime":7.664,"endTime":8.941},{"text":"嵩","starTime":8.941,"endTime":10.218}]}
第三步 让歌词动起来,文字匹配
我们继续采用计时器模拟的方式,来实现下歌词的匹配
let time = ~~(Math.random() * 20000) + 20000
const timeDom = document.querySelector('.time')
const curDom = document.querySelector('.cur-lrc')
// 这里是歌曲最大时间,我们假设超过就会直接重置掉时间
const maxTime = list[list.length - 1].time
setInterval(() => {
if (time > maxTime * 1000) time = 0
time = time + 16
}, 16)
const loop = () => {
timeDom.textContent = `当前时间: ${time} ms`
// 判断当前展示哪一行歌词
// 判断逻辑为,如果当前时间小于下一个,那么聚焦上一行
let i = 0;
while (i < list.length) {
if (time > list[i].starTime * 1000) {
i++
} else {
break
}
}
const curItem = list[i - 1];
if (curItem) {
curDom.textContent = `${curItem?.text}`
// 这里知道是哪一行了,需要计算当前行应该展示哪一个字,同时展示当前字的百分比
let j = 0
const children = curItem.children;
while (j < children.length) {
if (time / 1000 < children[j].starTime) {
break
} else {
j++
}
}
// 聚焦到小于开始时间的前一个字
j = j - 1
// 计算当前字应该走到的百分比
const precent = j / children.length
// 计算时间差值在当前字的持续时间占比,求出字的百分比
const wordPrecent = ((time / 1000) - children[j].starTime) / (children[j].endTime - children[j].starTime) * (1 / children.length)
// 渲染样式
const keyStyle = `-webkit-linear-gradient(left, rgb(230, 141, 25) ${precent * 100 + wordPrecent * 100}%, rgb(0,0,0) 0%)`
curDom.style.backgroundImage = keyStyle;
}
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
我们可以看到上面的例子,已经实现了时间和文字的对应,这里的实现也很简单,我们第一步是根据当前时间计算出我们的歌词是哪一行,然后计算当前时间应该聚焦哪一个字,然后根据时间差值所占的比例,计算出当前字应该渲染的百分比,加起来,就是整行歌词应该展示的百分比了
第四步 歌曲联动
又到了我们最后一步的环节了,上面已经实现了全部的核心代码,那么我们只需要在这一步,把流动的时间轴改变为歌词就行了
直接上例子
总结
上面基本上就是歌词解析的全部核心内容了,在实际项目可以做到更优的匹配策略和更好的过渡效果
夹带私货
推荐下我自己做的本地音乐播放器,一键上传本地音乐和歌词后,储存在本地随时听