前端歌词同步展示与粒子文本特效
获取歌词
歌词文件一般是.lrc 文件,里面的内容大概是这样的
txt
[00:00.0]东京不太热 - 洛天依
[00:05.33]词:Z新豪
[00:10.67]曲:Z新豪
[00:16.0]编曲:Z新豪
[00:21.34]硬盘里的女神下了又删
左边是时间点,右边是歌词或者其它信息。只需要使用正则就可以提取出来了。
ts
function parseLRC(lrcContent: string) {
const lines = lrcContent.split('\n');
const lyrics = [];
for (const line of lines) {
const match = line.match(/\[(\d{2}:\d{2}\.\d+)\](.*)/);
if (match) {
const time = match[1];
const text = match[2].trim();
const [minutes, seconds] = time.split(':');
const totalSeconds = parseInt(minutes) * 60 + parseFloat(seconds);
lyrics.push({ time: totalSeconds, text });
}
}
// {
// time: number;
// text: string;
// }[];
return lyrics;
}
接下来有两种方法同步展示歌词,都差不多。
一种是监听audio
元素的timeupdate
事件,audio
自己有一个currentTime
属性,可以从里面读当前播放到哪里了。
另一种是每隔一段时间就获取一次audio
的currentTime
,用setInterval
或者requestAnimationFrame
。这里我选择的是requestAnimationFrame
。
粒子特效
这里可以看这个视频粒子时钟【渡一教育】,我做的其实只是改造了一下。
我在这里放一下粒子时钟的代码,如果不想敲可以直接 copy.
ts
function getRandom(min: number, max: number) {
return Math.random() * (max - min) + min;
}
class Particle {
private ctx: CanvasRenderingContext2D;
private size: number;
private x: number;
private y: number;
constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
this.size = getRandom(2 * devicePixelRatio, 7 * devicePixelRatio);
const r = Math.min(canvas.width, canvas.height) / 2;
const rad = (getRandom(0, 2 * Math.PI) * 180) / Math.PI;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
this.x = cx + r * Math.cos(rad);
this.y = cy + r * Math.sin(rad);
}
draw() {
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
this.ctx.fillStyle = '#544544a0';
this.ctx.fill();
}
moveTo(tx: number, ty: number) {
const duration = 500;
const sx = this.x;
const sy = this.y;
const xSpeed = (tx - sx) / duration;
const ySpeed = (ty - sy) / duration;
const startTime = Date.now();
const _move = () => {
const t = Date.now() - startTime;
const x = sx + xSpeed * t;
const y = sy + ySpeed * t;
this.x = x;
this.y = y;
if (t >= duration) {
this.x = tx;
this.y = ty;
return;
}
requestAnimationFrame(_move);
};
_move();
}
}
class ParticleGroup {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private particles: Particle[];
private text: string;
private textSize: number;
constructor(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
textSize: number = 70
) {
this.canvas = canvas;
this.ctx = ctx;
this.particles = [];
this.text = '';
this.textSize = textSize * devicePixelRatio || 70 * devicePixelRatio;
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
update() {
const curText = new Date().toLocaleTimeString();
if (this.text === curText) {
return;
}
this.clear();
this.text = curText;
const { width, height } = this.canvas;
this.ctx.fillStyle = '#000';
this.ctx.textBaseline = 'middle';
this.ctx.font = `${this.textSize}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.fillText(this.text, width / 2, height / 2);
const points = this.getPoints();
this.clear();
for (let i = 0; i < points.length; i++) {
const [x, y] = points[i];
let p = this.particles[i];
if (!p) {
p = new Particle(this.canvas, this.ctx);
this.particles.push(p);
}
p.moveTo(x, y);
}
if (points.length < this.particles.length) {
this.particles.splice(points.length);
}
}
getPoints() {
const points = [];
const { data } = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
);
const gap = 6;
for (let i = 0; i < this.canvas.width; i += gap) {
for (let j = 0; j < this.canvas.height; j += gap) {
const index = (i + j * this.canvas.width) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (r === 0 && g === 0 && b === 0 && a > 0) {
points.push([i, j]);
}
}
}
return points;
}
draw() {
this.clear();
this.update();
for (const p of this.particles) {
p.draw();
}
requestAnimationFrame(this.draw.bind(this));
}
}
这里可能跟原视频不太一样,不过效果应该是类似的。
视频中直接使用new Date().toLocaleTimeString()
作为字符串,可以拓展成由外部传递一个函数来生成字符串。
ts
class ParticleGroup {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private particles: Particle[]
private textGenerator: () => string
private text: string
private textSize: number
constructor(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
generator: () => string
textSize: number = 70,
) {
this.canvas = canvas
this.ctx = ctx
this.particles = []
this.textGenerator = generator
this.text = ''
this.textSize = textSize * devicePixelRatio || 70 * devicePixelRatio
}
}
这样传递一个() => new Date().toLocaleTimeString()
也是一样的效果。不过这样会有一个问题,假如我要遍历一个数组,那肯定要在某一个时间段是同一个值,不太可能每次执行都是下一个值,这样子文字变化太快了。那我可能就需要这样写了。
ts
const words = ['hallo', 'how are you', 'thank you'];
let last = new Date();
let i = 0;
const generator = () => {
const now = new Date();
if (now.getTime() - last.getTime() >= 1000) {
last = now;
i++;
if (i >= words.length) {
i = 0;
}
}
return words[i];
};
但实际上外部几乎用不上这些 last 和 i,如果情况再复杂一些的话就外部的冗余变量就会更多。这种情况用闭包更好一些,状态由自己管理。
ts
const generator = () => {
let last = new Date();
let i = 0;
return () => {
const now = new Date();
if (now.getTime() - last.getTime() >= 1000) {
last = now;
i++;
if (i >= words.length) {
i = 0;
}
}
return words[i];
};
};
之前的构造函数也改一下
ts
class ParticleGroup {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private generator: () => string;
private particles: Particle[];
private text: string;
private textSize: number;
constructor(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
generator: () => () => string,
textSize: number = 70
) {
this.canvas = canvas;
this.ctx = ctx;
this.generator = generator();
this.text = '';
this.particles = [];
this.textSize = textSize * devicePixelRatio || 70 * devicePixelRatio;
}
}
再把使用粒子特效封装成一个函数
ts
export interface ParticleTextOptions {
width?: number;
height?: number;
fontSize?: number;
textGenerator: () => () => string;
}
export const renderParticleText = (
canvas: HTMLCanvasElement,
options: ParticleTextOptions
) => {
if (!canvas) {
throw new Error('canvas is null');
}
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('ctx is null');
}
const { width, height, fontSize, textGenerator } = options;
canvas.width = width || 800;
canvas.height = height || 600;
const particleGroup = new ParticleGroup(
canvas,
ctx,
textGenerator,
fontSize || 70 * devicePixelRatio
);
return { draw: particleGroup.draw.bind(particleGroup) };
};
之后要使用只需要调用这个函数就可以了。
粒子歌词
事已至此,只需要实现这个 textGenerator 即可。
ts
const audio = useTemplateRef<HTMLAudioElement>('audio');
const textGenerator = () => {
let currentLRC = lrc[0];
let nextLRC = lrc[1];
let index = 0;
const len = lrc.length;
const audioElement = audio.value;
if (!audioElement) {
return () => 'No audio element found';
}
return () => {
const currentTime = audioElement.currentTime;
if (currentLRC.time <= currentTime && nextLRC.time > currentTime) {
return currentLRC.text;
}
while (currentLRC.time > currentTime && index > 0) {
nextLRC = lrc[index];
index--;
currentLRC = lrc[index];
}
while (nextLRC.time <= currentTime && index < len - 1) {
currentLRC = lrc[index];
index++;
nextLRC = lrc[index];
}
if (index === len - 1 && nextLRC.time <= currentTime) {
return lrc[index].text;
}
return currentLRC.text;
};
};
我这里使用了 vue3 的useTemplateRef
,直接使用 document 获取元素也可以.
完整代码
完整代码如下:
vue
<script lang="ts" setup>
import { useTemplateRef, onMounted } from 'vue';
import { renderParticleText } from '@/composables/useParticleText';
import words from '@/assets/1.lrc?raw';
const { width, height, fontSize } = defineProps<{
width?: number;
height?: number;
fontSize?: number;
}>();
const audio = useTemplateRef<HTMLAudioElement>('audio');
const canvas = useTemplateRef<HTMLCanvasElement>('canvas');
const lrc = parseLRC(words);
const textGenerator = () => {
let currentLRC = lrc[0];
let nextLRC = lrc[1];
let index = 0;
const len = lrc.length;
const audioElement = audio.value;
if (!audioElement) {
return () => 'No audio element found';
}
return () => {
const currentTime = audioElement.currentTime;
if (currentLRC.time <= currentTime && nextLRC.time > currentTime) {
return currentLRC.text;
}
while (currentLRC.time > currentTime && index > 0) {
nextLRC = lrc[index];
index--;
currentLRC = lrc[index];
}
while (nextLRC.time <= currentTime && index < len - 1) {
currentLRC = lrc[index];
index++;
nextLRC = lrc[index];
}
if (index === len - 1 && nextLRC.time <= currentTime) {
return lrc[index].text;
}
return currentLRC.text;
};
};
function parseLRC(lrcContent: string) {
const lines = lrcContent.split('\n');
const lyrics = [];
for (const line of lines) {
const match = line.match(/\[(\d{2}:\d{2}\.\d+)\](.*)/);
if (match) {
const time = match[1];
const text = match[2].trim();
const [minutes, seconds] = time.split(':');
const totalSeconds = parseInt(minutes) * 60 + parseFloat(seconds);
lyrics.push({ time: totalSeconds, text });
}
}
return lyrics;
}
onMounted(() => {
if (!canvas.value) {
console.error('canvas is null');
return;
}
const { draw } = renderParticleText(canvas.value, {
width,
height,
fontSize,
textGenerator,
});
draw();
});
</script>
<template>
<div class="flex flex-col">
<canvas
class="absolute"
ref="canvas"
></canvas>
<audio
src="/1.mp3"
controls
ref="audio"
></audio>
</div>
</template>
ts
function getRandom(min: number, max: number) {
return Math.random() * (max - min) + min;
}
class Particle {
private ctx: CanvasRenderingContext2D;
private size: number;
private x: number;
private y: number;
constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
this.size = getRandom(1.3 * devicePixelRatio, 4 * devicePixelRatio);
const r = Math.min(canvas.width, canvas.height) / 2;
const rad = (getRandom(0, 2 * Math.PI) * 180) / Math.PI;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
this.x = cx + r * Math.cos(rad);
this.y = cy + r * Math.sin(rad);
}
draw() {
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
this.ctx.fillStyle = '#544544f0';
this.ctx.fill();
}
moveTo(tx: number, ty: number) {
const duration = 500;
const sx = this.x;
const sy = this.y;
const xSpeed = (tx - sx) / duration;
const ySpeed = (ty - sy) / duration;
const startTime = Date.now();
const _move = () => {
const t = Date.now() - startTime;
const x = sx + xSpeed * t;
const y = sy + ySpeed * t;
this.x = x;
this.y = y;
if (t >= duration) {
this.x = tx;
this.y = ty;
return;
}
requestAnimationFrame(_move);
};
_move();
}
}
class ParticleGroup {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private generator: () => string;
private particles: Particle[];
private text: string;
private textSize: number;
constructor(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
generator: () => () => string,
textSize: number = 70
) {
this.canvas = canvas;
this.ctx = ctx;
this.generator = generator();
this.text = '';
this.particles = [];
this.textSize = textSize * devicePixelRatio || 70 * devicePixelRatio;
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
update() {
const curText = this.generator();
if (this.text === curText) {
return;
}
this.clear();
this.text = curText;
const { width, height } = this.canvas;
this.ctx.fillStyle = '#000';
this.ctx.textBaseline = 'middle';
this.ctx.font = `${this.textSize}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.fillText(this.text, width / 2, height / 2);
const points = this.getPoints();
this.clear();
for (let i = 0; i < points.length; i++) {
const [x, y] = points[i];
let p = this.particles[i];
if (!p) {
p = new Particle(this.canvas, this.ctx);
this.particles.push(p);
}
p.moveTo(x, y);
}
if (points.length < this.particles.length) {
this.particles.splice(points.length);
}
}
getPoints() {
const points = [];
const { data } = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
);
const gap = 6;
for (let i = 0; i < this.canvas.width; i += gap) {
for (let j = 0; j < this.canvas.height; j += gap) {
const index = (i + j * this.canvas.width) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (r === 0 && g === 0 && b === 0 && a > 0) {
points.push([i, j]);
}
}
}
return points;
}
draw() {
this.clear();
this.update();
for (const p of this.particles) {
p.draw();
}
requestAnimationFrame(this.draw.bind(this));
}
}
export interface ParticleTextOptions {
width?: number;
height?: number;
fontSize?: number;
textGenerator: () => () => string;
}
export const renderParticleText = (
canvas: HTMLCanvasElement,
options: ParticleTextOptions
) => {
if (!canvas) {
throw new Error('canvas is null');
}
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('ctx is null');
}
const { width, height, fontSize, textGenerator } = options;
canvas.width = width || 800;
canvas.height = height || 600;
const particleGroup = new ParticleGroup(
canvas,
ctx,
textGenerator,
fontSize || 70 * devicePixelRatio
);
return { draw: particleGroup.draw.bind(particleGroup) };
};