/** * 音频可视化 - 粒子系统与圆形频谱绘制 * * 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。 * 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。 */ /** 单个粒子的状态描述 */ export interface Particle { x: number // 当前 x 坐标 y: number // 当前 y 坐标 vx: number // x 方向速度 vy: number // y 方向速度 life: number // 已存活帧数 maxLife: number // 最大存活帧数 size: number // 粒子半径 r: number // 颜色 R 分量 g: number // 颜色 G 分量 b: number // 颜色 B 分量 } /** 粒子数量上限, 防止性能问题 */ const MAX_PARTICLES = 200 /** * 在唱片边缘生成新粒子 * * 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。 * 颜色在蓝紫色调范围内随机偏移。 * * @param particles - 粒子数组(会被直接修改) * @param centerX - 唱片圆心 x 坐标 * @param centerY - 唱片圆心 y 坐标 * @param discRadius - 唱片半径(像素) * @param energy - 低频能量(0~1), 决定生成数量和粒子大小 * @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移 */ export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) { const count = Math.floor(energy * 4) for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) { const angle = Math.random() * Math.PI * 2 const speed = 0.5 + energy * 2 + highEnergy * 1.5 const hue = Math.random() particles.push({ x: centerX + Math.cos(angle) * (discRadius + 4), y: centerY + Math.sin(angle) * (discRadius + 4), vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8), vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8), life: 1, maxLife: 40 + Math.random() * 40, size: 1.5 + energy * 2 + Math.random() * 1.5, r: Math.floor(100 + hue * 155), g: Math.floor(180 - hue * 100 + highEnergy * 60), b: Math.floor(220 + hue * 35), }) } } /** * 更新粒子物理状态并绘制到 Canvas * * 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。 * 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。 * * @param ctx - Canvas 2D 渲染上下文 * @param particles - 粒子数组(会被直接修改, 移除死亡粒子) */ export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i] p.x += p.vx p.y += p.vy p.vx *= 0.98 p.vy *= 0.98 p.life += 1 const progress = p.life / p.maxLife if (progress >= 1) { particles.splice(i, 1) continue } const alpha = 1 - progress const size = p.size * (1 - progress * 0.5) ctx.beginPath() ctx.arc(p.x, p.y, size, 0, Math.PI * 2) ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})` ctx.fill() // 发光效果: 在粒子外围画一个更大的半透明圆 if (size > 1.5) { ctx.beginPath() ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2) ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})` ctx.fill() } } } /** drawCircularSpectrum 的入参 */ export interface SpectrumParams { ctx: CanvasRenderingContext2D width: number // Canvas 宽度 height: number // Canvas 高度 dataArray: Uint8Array // Web Audio API 频率数据 } /** 频率能量分析结果 */ export interface EnergyResult { lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯 highEnergy: number // 高频段能量(0~1), 对应人声/镲片 } /** * 从频率数据中计算低频和高频的平均能量 * * 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。 * 用于驱动粒子系统的生成参数。 * * @param dataArray - FFT 频率数据(0~255) * @returns 低频和高频的归一化能量值 */ function computeFrequencyEnergy(dataArray: Uint8Array): EnergyResult { const barCount = dataArray.length let lowSum = 0, highSum = 0 const lowEnd = Math.floor(barCount * 0.25) const highStart = Math.floor(barCount * 0.6) for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i] for (let i = highStart; i < barCount; i++) highSum += dataArray[i] return { lowEnergy: lowSum / (lowEnd * 255), highEnergy: highSum / ((barCount - highStart) * 255), } } /** * 绘制圆形音频频谱 * * 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。 * 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。 * 绘制完成后自动计算并返回频率能量, 供粒子系统使用。 * * @param params - 包含 Canvas 上下文、尺寸和频率数据 * @returns 低频/高频能量分析结果 */ export function drawCircularSpectrum(params: SpectrumParams): EnergyResult { const { ctx, width, height, dataArray } = params const centerX = width / 2 const centerY = height / 2 const discRadius = Math.min(width, height) * 0.30 const barCount = dataArray.length const maxBarLength = Math.min(width, height) * 0.18 for (let i = 0; i < barCount; i++) { const value = dataArray[i] const barLength = (value / 255) * maxBarLength if (barLength < 1) continue const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2 const startX = centerX + Math.cos(angle) * (discRadius + 2) const startY = centerY + Math.sin(angle) * (discRadius + 2) const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength) const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength) const ratio = i / barCount const r = Math.floor(100 + ratio * 155) const g = Math.floor(180 - ratio * 100) const b = Math.floor(230 + ratio * 25) const alpha = 0.4 + (value / 255) * 0.6 ctx.beginPath() ctx.moveTo(startX, startY) ctx.lineTo(endX, endY) ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})` ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7) ctx.lineCap = 'round' ctx.stroke() } return computeFrequencyEnergy(dataArray) } /** * 将秒数格式化为 MM:SS 格式 * * @param sec - 秒数 * @returns 格式化后的时间字符串, 如 "03:25" */ export function formatTime(sec: number) { if (isNaN(sec)) return '00:00' const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` }