手把手系列-带你学会如何处理歌词解析

3,170 阅读4分钟

前言

不知道大家在使用音乐播放器时,有没有好奇,音乐歌词聚焦的实现,如何实现歌曲和歌词的对应,如何实现歌词的逐字显示的,作为一个热衷探索的人,今天来好好探究下,看看如何实现这些效果。

image.png

说明

一般来说歌词的标准文件格式为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)

image.png

我们可以看到上面的例子,已经实现了时间和文字的对应,这里的实现也很简单,我们第一步是根据当前时间计算出我们的歌词是哪一行,然后计算当前时间应该聚焦哪一个字,然后根据时间差值所占的比例,计算出当前字应该渲染的百分比,加起来,就是整行歌词应该展示的百分比了

第四步 歌曲联动

又到了我们最后一步的环节了,上面已经实现了全部的核心代码,那么我们只需要在这一步,把流动的时间轴改变为歌词就行了

直接上例子

image.png

总结

上面基本上就是歌词解析的全部核心内容了,在实际项目可以做到更优的匹配策略和更好的过渡效果

夹带私货

推荐下我自己做的本地音乐播放器,一键上传本地音乐和歌词后,储存在本地随时听

image.png

image.png

项目

项目预览地址

引用