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

但不是这种 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,这样等比例放大。

把按钮和频谱可视化注释掉,引入 lyricList
并且把 AxesHelper 展示出来。

这样,一句歌词就绘制好了。
然后我们在纵深方向绘制多句。

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

歌曲播放的时候移动歌词位置就好了。
具体的歌词可以从 .lrc 文件里解析出来:

这种是专门记录歌词的文件格式,前面的是这句歌词对应的歌曲时间,后面是歌词内容。
你可以从这里下载:

然后代码里解析一下:

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

分割下:

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;
})
})

这样,歌词就准备好了。
然后让它动起来。
我们解析歌词的时候记录下每句歌词的毫秒数和 z 的位置的对应关系:

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]);
}
打印下看下结果:

打印了一个二维数组,每个元素是歌词毫秒数和歌词位置的对应关系。
把播放按钮展示出来:

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

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);
}
看下效果:

没啥问题,音乐和歌词是同步的。
但我们还要处理下边界情况:

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

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 删除凋。
看下效果:

现在就不是直接过去了,而是有个缓动动画的过程。
但你可能会发现,暂停后再开始,歌词就不会同步了:

为什么呢?
因为暂停后 audio.context.currentTime 还是在不断增加的。
我们打印下:

console.log(audio.context.currentTime);
首先,没点开始的时候 currentTime 一直是 0:

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

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

因为 audio.context.currentTime 只是从点击开始后过了多长时间,暂停不影响这个时间。
所以我们就不能用它了,要自己来计时:

首先,声明两个变量,costTime 是已经度过的总时间,startTime 是点击播放按钮后度过的时间。
let costTime = 0;
let startTime = 0;
每次的 currentTime 就是已经度过的总时间 costTime + (当前时间减去点播放按钮时的时间)
let currentTime = costTime + Date.now() - startTime;
console.log(currentTime);
const mSeconds = currentTime;
然后在点击按钮的时候记录下 startTime,更新下 costTime:

startTime = Date.now();
costTime += Date.now() - startTime;
试下效果:

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

暂停后一段时间再开始,计时继续,歌词依然能正确同步。
这样,我们的歌词部分就完成了。
把音乐频谱放出来,然后调一下歌词位置:

lyricList.position.y = 650;

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

这样就好了:

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


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


这样就挡不住歌词了。
然后我们优化下代码,把更新歌词位置的代码抽成函数:


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,因为它在暂停后不会停止增加,我们需要自己记录一个播放的时间,暂停、播放的时候计时。
这样,我们歌词同步就完成了。