diff --git a/src/views/api/aplayer/music-player-panel.vue b/src/views/api/aplayer/music-player-panel.vue index d7bf55e..8bbfa83 100644 --- a/src/views/api/aplayer/music-player-panel.vue +++ b/src/views/api/aplayer/music-player-panel.vue @@ -124,6 +124,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue' import { MusicPlayerItem, StatType } from '@/model/api/music' import { parseLrc, adaptingThemeColor } from './utils' +import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer' import Hls from 'hls.js' const props = defineProps<{ @@ -214,6 +215,9 @@ let animationId: number | null = null let dataArray: Uint8Array | null = null let visualizerConnected = false +// 粒子系统 +const particles: Particle[] = [] + function initVisualizer(audio: HTMLAudioElement) { if (visualizerConnected) return audioCtx = new AudioContext() @@ -239,38 +243,13 @@ function drawVisualizer() { analyser.getByteFrequencyData(dataArray) ctx.clearRect(0, 0, width, height) + const { lowEnergy, highEnergy } = drawCircularSpectrum({ ctx, width, height, dataArray }) + const centerX = width / 2 const centerY = height / 2 - // 封面占 disc 的 52% + 6px border, 频谱从封面外边缘开始 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() - } + spawnParticles(particles, centerX, centerY, discRadius, lowEnergy, highEnergy) + updateAndDrawParticles(ctx, particles) animationId = requestAnimationFrame(drawVisualizer) } @@ -286,6 +265,7 @@ function stopVisualizer() { cancelAnimationFrame(animationId) animationId = null } + particles.length = 0 } function destroyVisualizer() { @@ -371,12 +351,6 @@ function onVolumeInput(e: Event) { } } -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')}` -} function initAudio() { const media = audioEl.value diff --git a/src/views/api/aplayer/visualizer.ts b/src/views/api/aplayer/visualizer.ts new file mode 100644 index 0000000..f93432c --- /dev/null +++ b/src/views/api/aplayer/visualizer.ts @@ -0,0 +1,195 @@ +/** + * 音频可视化 - 粒子系统与圆形频谱绘制 + * + * 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。 + * 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。 + */ + +/** 单个粒子的状态描述 */ +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')}` +}