Skip to content

73. 实战:3D 音乐播放器(三)

Published:

这节我们来实现歌词的同步:

image.png

但不是这种 2D 的列表,而是 3D 的。

我们用 PlaneGeometry + canvas 画文字的方式来实现歌词的展示。

这些平面在 z 轴方向并列,根据歌曲时长同比例的改变 z,就实现了歌词同步的效果。

创建 src/lyric.js

import * as THREE from 'three';

const lyricList = new THREE.Group();

function createCanvas(text, width) {
    const dpr = window.devicePixelRatio;
    const canvas = document.createElement("canvas");
    const w = canvas.width = width * dpr;
    const h = canvas.height = 100 * dpr;

    const c = canvas.getContext('2d');
    c.translate(w / 2, h / 2);
    c.fillStyle = "#ffffff";
    c.font = "normal 24px 微软雅黑";
    c.textBaseline = "middle";
    c.textAlign = "center";
    c.fillText(text, 0, 0);

    return canvas;
}

function createLyricItem(text) {
    const texture = new THREE.CanvasTexture(createCanvas(text, text.length * 30));
    const g = new THREE.PlaneGeometry(text.length * 300, 500);
    const m = new THREE.MeshPhysicalMaterial({
        map: texture,
        transparent: true,
        roughness: 0.3
    });
    const plane = new THREE.Mesh(g, m);
    plane.position.y = 41;
    return plane;
}


const lyricItem = createLyricItem('你好,我是 superman 神光');
lyricList.add(lyricItem);

export default lyricList;

用 canvas 来写 text 的文字,canvas 的宽度通过参数传入。

canvas 宽度是文本长度 * 30 算出来的,因为每个文字宽度是 24 像素。

PlaneGeometry 宽度是文本长度 * 300,这样等比例放大。

image.png

把按钮和频谱可视化注释掉,引入 lyricList

并且把 AxesHelper 展示出来。

2025-04-16 11.52.22.gif

这样,一句歌词就绘制好了。

然后我们在纵深方向绘制多句。

image.png

for (let i = 0; i< 10; i++) {
    const lyricItem = createLyricItem('你好,我是 superman 神光');
    lyricList.add(lyricItem);
    lyricItem.position.z = -i * 500;
}

image.png

歌曲播放的时候移动歌词位置就好了。

具体的歌词可以从 .lrc 文件里解析出来:

image.png

这种是专门记录歌词的文件格式,前面的是这句歌词对应的歌曲时间,后面是歌词内容。

你可以从这里下载:

image.png

然后代码里解析一下:

image.png

fetch('./superman.lrc').then((res) => {
    return res.text()
}).then(content => {
    console.log(content);
})

image.png

分割下:

image.png

fetch('./superman.lrc').then((res) => {
    return res.text()
}).then(content => {
    const lyrics = content.split('\n');
    lyrics.forEach((item, i) => {
        const lyricItem = createLyricItem(item.slice(10));
        lyricList.add(lyricItem);
        lyricItem.position.z = -i * 1000;
    })
})

image.png

这样,歌词就准备好了。

然后让它动起来。

我们解析歌词的时候记录下每句歌词的毫秒数和 z 的位置的对应关系:

image.png

export const lyricPositions = [];
const timeStr = item.slice(0, 10);
if(timeStr.length) {
    const minute = parseInt(timeStr.slice(1, 3));
    const second = parseInt(timeStr.slice(4, 6));
    const mSecond = parseInt(timeStr.slice(7, 9));

    const time = minute * 60 * 1000 + second * 1000 + mSecond;
    lyricPositions.push([time, i * 1000]);
}

打印下看下结果:

image.png

打印了一个二维数组,每个元素是歌词毫秒数和歌词位置的对应关系。

把播放按钮展示出来:

image.png

每帧渲染的时候,拿到当前播放时间 audio.context.currentTime,判断下是不是在这句歌词和下句歌词的播放时间范围内。

是的话就修改 position.z 为这句歌词的 z

image.png

let i = 0;
function render() {
    if(lyricPositions.length && audio.isPlaying) {
      const mSeconds = Math.floor(audio.context.currentTime * 1000);
      if(mSeconds > lyricPositions[i][0] && mSeconds < lyricPositions[i + 1][0]) {
        lyricList.position.z = lyricPositions[i][1];
        i++;
      }
    }

    updateHeight();
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

看下效果:

2025-04-16 14.09.02.gif

没啥问题,音乐和歌词是同步的。

但我们还要处理下边界情况:

image.png

if(lyricPositions.length && audio.isPlaying) {
  const mSeconds = Math.floor(audio.context.currentTime * 1000);
  if(i >= lyricPositions.length - 1) {
    lyricList.position.z = lyricPositions[lyricPositions.length - 1][1];
  } else if(mSeconds > lyricPositions[i][0] && mSeconds < lyricPositions[i + 1][0]) {
    lyricList.position.z = lyricPositions[i][1];
    i++;
  }
}

如果 i 是 lyricPositions.length - 1 也就是最后一个了,那就直接修改 position.z 为最后一句歌词的。

直接修改 position.z 太生硬了,我们改成 tween 来做缓动动画:

npm install --save @tweenjs/tween.js

image.png

import { Easing, Group, Tween } from '@tweenjs/tween.js';
const tweenGroup = new Group();
let i = 0;
function render() {
    if(lyricPositions.length && audio.isPlaying) {
      const mSeconds = Math.floor(audio.context.currentTime * 1000);
      if(i >= lyricPositions.length - 1) {
        lyricList.position.z = lyricPositions[lyricPositions.length - 1][1];
      } else if(mSeconds > lyricPositions[i][0] && mSeconds < lyricPositions[i + 1][0]) {
        const tween= new Tween(lyricList.position).to({
            z: lyricPositions[i][1] + 300
        }, 300)
        .easing(Easing.Quadratic.InOut)
        .repeat(0)
        .start()
        .onComplete(() => {
            tweenGroup.remove(tween);
        })
        tweenGroup.add(tween);
        i++;
      }
    }

    tweenGroup.update();
    updateHeight();
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

创建一个 Group 来管理所有的 tween,每帧渲染的时候调用下 group.update()

把之前直接修改 position.z 改为用 tween 做缓动动画。

onComplete 的时候把这个 tween 删除凋。

看下效果:

2025-04-16 15.20.07.gif

现在就不是直接过去了,而是有个缓动动画的过程。

但你可能会发现,暂停后再开始,歌词就不会同步了:

2025-04-16 18.36.58.gif

为什么呢?

因为暂停后 audio.context.currentTime 还是在不断增加的。

我们打印下:

image.png

console.log(audio.context.currentTime);

首先,没点开始的时候 currentTime 一直是 0:

2025-04-16 18.39.52.gif

点击开始后,currentTime 开始增加,歌词也跟着同步动:

2025-04-16 18.40.42.gif

但点击暂停后,currentTime 还是在增加:

2025-04-16 18.41.01.gif

因为 audio.context.currentTime 只是从点击开始后过了多长时间,暂停不影响这个时间。

所以我们就不能用它了,要自己来计时:

image.png

首先,声明两个变量,costTime 是已经度过的总时间,startTime 是点击播放按钮后度过的时间。

let costTime = 0;
let startTime = 0;

每次的 currentTime 就是已经度过的总时间 costTime + (当前时间减去点播放按钮时的时间)

let currentTime = costTime + Date.now() - startTime;
console.log(currentTime);

const mSeconds = currentTime;

然后在点击按钮的时候记录下 startTime,更新下 costTime:

image.png

startTime = Date.now();
costTime += Date.now() - startTime;

试下效果:

2025-04-16 18.59.53.gif

现在,播放的时候歌词能正确同步。

2025-04-16 19.02.01.gif

暂停后一段时间再开始,计时继续,歌词依然能正确同步。

这样,我们的歌词部分就完成了。

把音乐频谱放出来,然后调一下歌词位置:

image.png

lyricList.position.y = 650;

2025-04-16 19.04.55.gif

现在会有一些特别大的歌词,我们把近处的裁剪下:

image.png

这样就好了:

2025-04-16 19.16.54.gif

然后我们调一下颜色,黄色和白色的歌词混在一起看不清楚,我们换个颜色:

image.png

2025-04-16 21.38.31.gif

这个频谱可视化有点高,调低一点:

image.png

2025-04-17 09.20.19.gif

这样就挡不住歌词了。

然后我们优化下代码,把更新歌词位置的代码抽成函数:

image.png

image.png

updateLyricPosition();
function updateLyricPosition() {
  if(lyricPositions.length && audio.isPlaying) {
    let currentTime = costTime + Date.now() - startTime;

    const mSeconds = currentTime;
    if(i >= lyricPositions.length - 1) {
      lyricList.position.z = lyricPositions[lyricPositions.length - 1][1];
    } else if(mSeconds > lyricPositions[i][0] && mSeconds < lyricPositions[i + 1][0]) {
      const tween= new Tween(lyricList.position).to({
          z: lyricPositions[i][1] + 300
      }, 300)
      .easing(Easing.Quadratic.InOut)
      .repeat(0)
      .start()
      .onComplete(() => {
          tweenGroup.remove(tween);
      })
      tweenGroup.add(tween);
      i++;
    }
  }
}

案例代码上传了小册仓库

总结

这节我们实现了歌词的同步。

首先解析 lrc 文件,从中提取歌词,用 canvas 画出来,作为 PlaneGeometry 的纹理。

这些平面在 z 轴方向依次排列,我们拿到每句歌词的时间和 position.z 的对应关系,当歌词播放的时候,定位到对应的歌词,用 Tween 做缓动动画。

这里的时间不能直接用 audio.context.currentTime,因为它在暂停后不会停止增加,我们需要自己记录一个播放的时间,暂停、播放的时候计时。

这样,我们歌词同步就完成了。

评论