以《前前前世》为例,手把手带你用TypeScript写出一个歌词解析插件

2,665 阅读6分钟

之前用Vue3写了一个手机端的网易云音乐APP,写到播放组件的时候,我发现开源社区并没有让我满意的歌词解析插件,也就是说你想实现解析歌词只能自己手动去写,这里我参考了ustbhuangyilyric-parser,不过由于他的算法并不支持解析网易云的歌词,所以我打算自己手动实现一波

1. 前言

你读这篇文章应具有的知识储备

1.TypeScript的基本语法
2.JavaScript的基本语法

安装

npm i lyric-resolver

Github地址

github.com/SnowingFox/…

例子

github.com/SnowingFox/…

应用场景

当你想做一个歌词滚动,类似于网易云音乐客户端的那种歌词效果时,你就会用到这个插件

歌词数据分析

这是一个基本的歌词数据

[00:00.000] 作词 : 野田洋次郎
[00:01.000] 作曲 : 野田洋次郎
[00:19.600]
[00:20.000]やっと眼を覚ましたかい それなのになぜ眼も合わせやしないんだい?
[00:30.090]「遅いよ」と怒る君 これでもやれるだけ飛ばしてきたんだよ
[00:38.720]
[00:39.670]心が身体を追い越してきたんだよ
[00:44.340]
[00:45.340]君の髪や瞳だけで胸が痛いよ
[00:50.560]同じ時を吸いこんで離したくないよ
[00:55.200]遥か昔から知る その声に
[01:00.260]生まれてはじめて 何を言えばいい?
[01:07.240]
[01:07.640]君の前前前世から僕は 君を探しはじめたよ
[01:12.280]そのぶきっちょな笑い方をめがけて やってきたんだよ
[01:17.260]
[01:17.630]君が全然全部なくなって チリヂリになったって
[01:22.430]もう迷わない また1から探しはじめるさ
[01:27.240]むしろ0から また宇宙をはじめてみようか
[01:32.670]
[01:43.440]どっから話すかな 君が眠っていた間のストーリー
[01:53.480]何億 何光年分の物語を語りにきたんだよ けどいざその姿この眼に映すと
[02:07.720]
[02:08.720]君も知らぬ君とジャレて 戯れたいよ
[02:13.530]君の消えぬ痛みまで愛してみたいよ
[02:18.480]銀河何個分かの 果てに出逢えた
[02:23.530]その手を壊さずに どう握ったならいい?
[02:30.500]
[02:31.000]君の前前前世から僕は 君を探しはじめたよ
[02:35.680]その騒がしい声と涙をめがけ やってきたんだよ
[02:40.550]
[02:40.990]そんな革命前夜の僕らを誰が止めるというんだろう
[02:45.720]もう迷わない 君のハートに旗を立てるよ
[02:50.740]君は僕から諦め方を 奪い取ったの
[02:55.880]
[03:53.030]前前前世から僕は 君を探しはじめたよ
[03:57.290]そのぶきっちょな笑い方をめがけて やってきたんだよ
[04:01.990]
[04:02.620]君が全然全部なくなって チリヂリになったって
[04:07.240]もう迷わない また1から探しはじめるさ
[04:12.370]何光年でも この歌を口ずさみながら
[04:18.090]

可以发现,歌词的时间会给出分钟、秒、毫秒的时间,如[03:58.970]就是 3min:58s:970ms,我们可以根据这个时间来计算歌词的时间,从而正确的向用户输出当前歌词应该是哪一行

基本用法


import Lyric, { HandlerParams } from 'lyric-resolver'

import { getLyric } from '../api/lyric.js'

export async function useLyric(): any {
    const lrc = await getLyric()
    const currentLyric = new Lyric(lrc, handleLyric)

    /*
    * @params curLineNum [number] Current line of lyric
    * @params txt [string] Current line's txt
    * 
    * @return void
    * */
    function handleLyric(payload: HandlerParams): void {
        const { curLineNum, txt } = payload
        // 你也可以向下面这样操作得到curLineNum
        const curLine: number = currentLyric.curLine
    }

    function play(): void {
      currentLyric.play()
    }
    function stop(): void {
      currentLyric.stop()
    }
    function togglePlay(): void {
      currentLyric.togglePlay()
    }
    function seek(time: number): void {
      currentLyric.seek(time)
    }
}

从代码的字面意思就能猜到实现了什么功能,我主要想说的是

const { curLineNum, txt } = payload

curLineNum就是拿到当前播放的是第几行歌词了,txt就是拿到当前的歌词文本

实现

时间计算

还记得之前说过的歌词时间计算么?想拿到歌词时间就需要我们先拿到[03:58.970]这里面的数据,将它转化为一个number类型的数据

正则匹配

const lyricTimeReg: RegExp = /\[(\d{2}):(\d{2}).(\d{2,3})]/g

我拿[01:07.640]君の前前前世から僕は 君を探しはじめたよ这一段歌词来解析,看看通过正则匹配后会得到什么格式的数据

const time = lyricTimeReg.exec('[01:07.640]君の前前前世から僕は 君を探しはじめたよ')

输出后我们得到了以下的数据

[
    "[01:07.640]",
    "01", // 分钟
    "07", // 秒
    "640" // 毫秒
]

我们接下来只需要一个辅助函数,将这个数据转化为以毫秒为单位的number

function transformRegTime(times: RegExpExecArray): number {
  const result: number[] = []
  times.forEach((time, index) => {
    if (index >= 1 && index <= 3) {
      result.push(parseInt(time))
    }
  })
  return (result[0] * 60 + result[1]) * 1000 + result[2]
}

最后测试一下输出的结果

transformRegTime(time!)  // 67640

得到了67640,也就是67s的位置,关于time!的非空断言则是exec会返回一个RegExpExecArray | null的类型

OK,时间计算问题解决了,现在来写插件主题部分了

主体实现

接口

// 每一行歌词的时间与歌词文本
interface Lines {
  lineTime: number
  txt: string
}

// 处理函数的参数定义
interface HandlerParams {
  curLineNum: number
  txt: string
}

// 播放状态
const enum PLAYING_STATE {
  stop = 0,
  playing = 1,
}

初始化

export default class Lyric {
  lines: Lines[]
  lrc: string
  state: any
  curLine: number
  startTime: number
  stopTime: number
  offset: number
  timer: any
  handler: any

  constructor(lrc: string, handler: (params: HandlerParams) => void) {
    this.lrc = lrc
    this.lines = []
    this.state = PLAYING_STATE.stop
    this.curLine = 0
    this.timer = null
    this.startTime = 0
    this.stopTime = 0
    this.offset = 0
    this.handler = handler
    this._init()
  }

  private _init(): void {
    this._initLines()
  }

  private _initLines(): void {
    this.lrc.split('\n').forEach((lrc) => {
      let time = lyricTimeReg.exec(lrc)

      // 后面传time就不用非空断言了
      if (!time) {
        return
      }
      let txt: string = lrc.replace(lineTimeReg, '')
      
      // 过滤空白文本
      if (txt === '') {
        return
      }

      this.lines.push({
        lineTime: transformRegTime(time),
        txt,
      })
    })
    
    // 升序,确保歌词是由它的时间来决定当前的位置
    this.lines.sort((a, b) => {
      return a.lineTime - b.lineTime
    })
  }
}

目前我们只需要关注

lines: Lines[]

对这一行的处理,当我们进行如下操作时

const lyric = new Lyric(txt)
console.log(lyric.lines)

最后打印出来的会是这样 (]DRI$7TZ(I)WINUR26_3.png OK,到这里我们对歌词的初始化解析就结束了,下面来实现一下歌词的播放,暂停功能

播放/暂停

export default class Lyric {
  lines: Lines[]
  lrc: string
  state: any
  curLine: number
  startTime: number
  stopTime: number
  offset: number
  timer: any
  handler: any

  constructor(lrc: string, handler: (params: HandlerParams) => void) {
    this.lrc = lrc
    this.lines = []
    this.state = PLAYING_STATE.stop
    this.curLine = 0
    this.timer = null
    this.startTime = 0
    this.stopTime = 0
    this.offset = 0
    this.handler = handler
    this._init()
  }
  
  private _playReset(): void {
    // 计算距离下一行歌词还有多少时间
    let { delay, targetIndex } = this._calculateDelay()
    this.curLine = targetIndex
    clearTimeout(this.timer)
    this.timer = setTimeout(() => {
      // hanlder的实现,也就是用户自己定义的对歌词信息获取的函数
      this._callHandler(this.curLine++)
      if (this.curLine < this.lines.length && this.state === PLAYING_STATE.playing) {
        this._playReset()
      }
    }, delay)
  }

  play(): void {
    this.state = PLAYING_STATE.playing
    this.startTime = Date.now()
    if (this.curLine < this.lines.length) {
      clearTimeout(this.timer)
      this._playReset()
    }
  }

  stop(): void {
    this.state = PLAYING_STATE.stop
    this.stopTime = Date.now()
    this.offset = this.offset + this.stopTime - this.startTime
    clearTimeout(this.timer)
  }

  togglePlay(): void {
    if (this.state === PLAYING_STATE.playing) {
      this.stop()
    } else {
      this.play()
    }
  }
  private _calculateDelay(): any {
    let delay: number = this._findLine(this.curLine).lineTime - this.offset
    let targetIndex: number = this.curLine

    let isFind: boolean = false
    if (delay < 0) {
      this.lines.forEach((line, index) => {
        delay = this._findLine(index).lineTime - this.offset
        if (delay >= 0 && !isFind) {
          targetIndex = index
          isFind = true
          return
        }
      })
    } else {
      this.lines.forEach((line, index) => {
        if (
          this.offset >= this._findLine(index - 1).lineTime &&
          this.offset < line.lineTime
        ) {
          targetIndex = index
          delay = this._findLine(targetIndex).lineTime - this.offset
        }
      })
    }
    return {
      delay,
      targetIndex,
    }
  }
  
  /**
   function handleLyric(payload: HandlerParams): void {
      const { curLineNum, txt } = payload
      const curLine: number = currentLyric.curLine
   }
   */
  private _callHandler(index: number): void {
    if (index < 0) {
      return
    }

    let curLine = index
    if (this._findCur()?.txt === '') {
    }
    try {
      this.handler({
        curLineNum: curLine,
        txt: this._findCur()?.txt.trim() || '',
      })
    } catch (e) {
      return
    }
  }

  // 找到当前歌词的信息
  private _findCur(): Lines {
    return this.lines[this.curLine]
  }

  // 找到指定行数的歌词信息,类型为Lines
  private _findLine(i: number): Lines {
    const lines = this.lines
    if (i < 0) {
      return lines[0]
    }
    if (i >= lines.length) {
      return lines[lines.length - 1]
    }
    return lines[i]
  }
}

对于歌词的播放,其实就是一个递归循环,通过计算播放下一行歌词还需要多少时间得到了delay来进行操作,不过这有一个痛点,也是我做这个插件时遇到的一个问题如何计算出下一次的delay?

下面我想解释一下几个数据

  • startTime 歌词播放的时间
  • stopTime 歌曲停止的时间
  • offset 当前歌词播放的进度时间

我是这样做的

play() {
  this.startTime = Date.now()
}
stop() {
  this.stopTime = Date.now()
  this.offset = this.offset + (this.stopTime - this.startTime)
}

这样就能正确的计算出进度了, 暂停播放的时间 - 开始播放的时间 = 本次播放的时间,注意我说的是本次播放,而不是歌曲播放,也就是你这一次播放,暂停后总共花了多少时间,然后之前的offset加上本次播放时间,就是当前歌曲播放的进度啦

seek

那有人会有疑问,如果我回退了怎么办?也就是当前是第N行,我要回到第N - M行去怎么办?或者快进到N + M行去怎么办? 这时候就可以用seek来实现

seek(offset: number): void {
  this.offset = offset
  this.play()
}

最终代码

interface Lines {
  lineTime: number
  txt: string
}

// 处理函数的参数定义
interface HandlerParams {
  curLineNum: number
  txt: string
}

// 播放状态
const enum PLAYING_STATE {
  stop = 0,
  playing = 1,
}

export default class Lyric {
  lines: Lines[]
  lrc: string
  state: any
  curLine: number
  startTime: number
  stopTime: number
  offset: number
  timer: any
  handler: any

  constructor(lrc: string, handler: (params: HandlerParams) => void) {
    this.lrc = lrc
    this.lines = []
    this.state = PLAYING_STATE.stop
    this.curLine = 0
    this.timer = null
    this.startTime = 0
    this.stopTime = 0
    this.offset = 0
    this.handler = handler
    this._init()
  }
  private _init(): void {
    this._initLines()
  }

  private _initLines(): void {
    this.lrc.split('\n').forEach((lrc) => {
      let time = lyricTimeReg.exec(lrc)

      // 后面传time就不用非空断言了
      if (!time) {
        return
      }
      let txt: string = lrc.replace(lineTimeReg, '')
      
      // 过滤空白文本
      if (txt === '') {
        return
      }

      this.lines.push({
        lineTime: transformRegTime(time),
        txt,
      })
    })
    
    // 升序,确保歌词是由它的时间来决定当前的位置
    this.lines.sort((a, b) => {
      return a.lineTime - b.lineTime
    })
  }
  private _playReset(): void {
    // 计算距离下一行歌词还有多少时间
    let { delay, targetIndex } = this._calculateDelay()
    this.curLine = targetIndex
    clearTimeout(this.timer)
    this.timer = setTimeout(() => {
      // hanlder的实现,也就是用户自己定义的对歌词信息获取的函数
      this._callHandler(this.curLine++)
      if (this.curLine < this.lines.length && this.state === PLAYING_STATE.playing) {
        this._playReset()
      }
    }, delay)
  }

  play(): void {
    this.state = PLAYING_STATE.playing
    this.startTime = Date.now()
    if (this.curLine < this.lines.length) {
      clearTimeout(this.timer)
      this._playReset()
    }
  }

  stop(): void {
    this.state = PLAYING_STATE.stop
    this.stopTime = Date.now()
    this.offset = this.offset + this.stopTime - this.startTime
    clearTimeout(this.timer)
  }

  togglePlay(): void {
    if (this.state === PLAYING_STATE.playing) {
      this.stop()
    } else {
      this.play()
    }
  }
  private _calculateDelay(): any {
    let delay: number = this._findLine(this.curLine).lineTime - this.offset
    let targetIndex: number = this.curLine

    let isFind: boolean = false
    if (delay < 0) {
      this.lines.forEach((line, index) => {
        delay = this._findLine(index).lineTime - this.offset
        if (delay >= 0 && !isFind) {
          targetIndex = index
          isFind = true
          return
        }
      })
    } else {
      this.lines.forEach((line, index) => {
        if (
          this.offset >= this._findLine(index - 1).lineTime &&
          this.offset < line.lineTime
        ) {
          targetIndex = index
          delay = this._findLine(targetIndex).lineTime - this.offset
        }
      })
    }
    return {
      delay,
      targetIndex,
    }
  }
  
  /**
   function handleLyric(payload: HandlerParams): void {
      const { curLineNum, txt } = payload
      const curLine: number = currentLyric.curLine
   }
   */
  private _callHandler(index: number): void {
    if (index < 0) {
      return
    }

    let curLine = index
    if (this._findCur()?.txt === '') {
        return
    }
    try {
      this.handler({
        curLineNum: curLine,
        txt: this._findCur()?.txt.trim() || '',
      })
    } catch (e) {
      return
    }
  }

  // 找到当前歌词的信息
  private _findCur(): Lines {
    return this.lines[this.curLine]
  }

  // 找到指定行数的歌词信息,类型为Lines
  private _findLine(i: number): Lines {
    const lines = this.lines
    if (i < 0) {
      return lines[0]
    }
    if (i >= lines.length) {
      return lines[lines.length - 1]
    }
    return lines[i]
  }
}

完结撒花

如果你对本篇文章的内容有什么不懂的欢迎在评论区留言,我尽量解答~