第八章:音频可视化 —— 让声音看得见

上一章我们认识了 AnalyserNode,知道它能提取频谱和波形数据。但从"拿到一个数组"到"画出震撼的可视化效果",中间还有很长的路要走。这一章我们把音频可视化从原理到实战完整打通——频谱柱状图、波形图、圆形频谱、粒子系统,每一种都有完整可运行的代码。


一、可视化的数学基础:FFT 是什么?

在动手写代码之前,必须先搞清楚 AnalyserNode 给你的数据到底是什么,以及它从哪里来。

时域 vs 频域

音频信号本质上是一个随时间变化的振幅序列,这叫**时域(Time Domain)表示。但人耳感知声音的方式更接近频域——我们能分辨出低音、中音、高音,这对应的是信号在不同频率(Frequency Domain)**上的能量分布。

**FFT(快速傅里叶变换)**就是把时域信号转换为频域表示的数学工具。AnalyserNode 在内部持续对音频信号做 FFT,然后把结果暴露给你。

时域信号(波形)          频域信号(频谱)
     │                        │
 幅度│  ╭─╮  ╭─╮             │  █
     │ ╭╯ ╰╮╭╯ ╰╮            │  █  █
     │╭╯   ╰╯   ╰╮           │  █  █  █
─────┼────────────→ 时间   ───┼──────────→ 频率
     │            │           │100 1k  10k Hz

fftSize 与频率分辨率

AnalyserNode 最重要的参数是 fftSize,它决定了 FFT 的窗口大小,直接影响频率分辨率和时间分辨率:

$$ \text{频率分辨率(Hz/bin)} = \frac{\text{采样率}}{\text{fftSize}} $$

以 44100Hz 采样率为例:

fftSize频率 bin 数量(frequencyBinCount频率分辨率时间分辨率
256128172.3 Hz/bin高(响应快)
102451243.1 Hz/bin
2048102421.5 Hz/bin低(响应慢)
819240965.4 Hz/bin极低

fftSize 越大,频率分辨率越高(能区分更细微的频率差异),但时间分辨率越低(对瞬态响应越迟钝)。对于可视化来说,2048 是最常用的平衡值

smoothingTimeConstant 的作用

这个参数(0~1)控制相邻帧之间的平滑程度,本质是一个指数移动平均:

$$ \text{output}[i] = \alpha \times \text{previous}[i] + (1 - \alpha) \times \text{current}[i] $$

值越接近 1,变化越平滑(但响应越慢);值越接近 0,变化越剧烈(但视觉跳动感强)。0.8 是视觉效果最舒适的常用值


二、AnalyserNode 数据获取详解

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
 
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.8;
analyser.minDecibels = -90;  // 频谱数据映射的最小 dB 值
analyser.maxDecibels = -10;  // 频谱数据映射的最大 dB 值
 
const bufferLength = analyser.frequencyBinCount; // 1024

四种数据获取方法

// 1. 频域数据(Uint8Array,0~255,对应 minDecibels~maxDecibels)
const freqDataByte = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(freqDataByte);
 
// 2. 频域数据(Float32Array,单位 dBFS,通常 -Infinity ~ 0)
const freqDataFloat = new Float32Array(bufferLength);
analyser.getFloatFrequencyData(freqDataFloat);
 
// 3. 时域数据(Uint8Array,0~255,128 对应 0 振幅)
const timeDataByte = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(timeDataByte);
 
// 4. 时域数据(Float32Array,-1.0~1.0)
const timeDataFloat = new Float32Array(bufferLength);
analyser.getFloatTimeDomainData(timeDataFloat);

如何选择

  • 频域数据用于频谱图(柱状图、圆形频谱)
  • 时域数据用于波形图(示波器)
  • Uint8Array 版本性能更好,适合实时可视化
  • Float32Array 版本精度更高,适合音频分析计算

三、渲染循环:requestAnimationFrame 的正确姿势

所有音频可视化的渲染都基于同一个模式:用 requestAnimationFrame 驱动的渲染循环。

let animationId = null;
 
function startVisualization() {
  function renderFrame() {
    animationId = requestAnimationFrame(renderFrame);
    // 每帧获取最新数据并绘制
    draw();
  }
  renderFrame();
}
 
function stopVisualization() {
  if (animationId) {
    cancelAnimationFrame(animationId);
    animationId = null;
  }
}
 
// 音频暂停时停止渲染,节省性能
audio.addEventListener('pause', stopVisualization);
audio.addEventListener('playing', startVisualization);

四、实战一:频谱柱状图

最经典的音频可视化形式,每根柱子代表一个频率 bin 的能量。

<canvas id="spectrumCanvas" width="800" height="300"></canvas>
class SpectrumVisualizer {
  constructor(analyser, canvas) {
    this.analyser = analyser;
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.bufferLength = analyser.frequencyBinCount;
    this.dataArray = new Uint8Array(this.bufferLength);
    this.animationId = null;
 
    // 响应式画布
    this._resizeObserver = new ResizeObserver(() => this._resize());
    this._resizeObserver.observe(canvas);
  }
 
  _resize() {
    this.canvas.width = this.canvas.offsetWidth * devicePixelRatio;
    this.canvas.height = this.canvas.offsetHeight * devicePixelRatio;
    this.ctx.scale(devicePixelRatio, devicePixelRatio);
  }
 
  start() {
    const draw = () => {
      this.animationId = requestAnimationFrame(draw);
      this._drawFrame();
    };
    draw();
  }
 
  stop() {
    cancelAnimationFrame(this.animationId);
    this.animationId = null;
  }
 
  _drawFrame() {
    this.analyser.getByteFrequencyData(this.dataArray);
 
    const { ctx, canvas } = this;
    const W = canvas.offsetWidth;
    const H = canvas.offsetHeight;
 
    // 清空画布(带透明度的黑色,产生拖尾效果)
    ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
    ctx.fillRect(0, 0, W, H);
 
    // 只取前 2/3 的 bin(高频部分通常没有有效信息)
    const usableBins = Math.floor(this.bufferLength * 0.65);
    const barWidth = W / usableBins;
 
    for (let i = 0; i < usableBins; i++) {
      const value = this.dataArray[i]; // 0 ~ 255
      const barHeight = (value / 255) * H;
 
      // 根据频率位置和能量生成渐变颜色
      const hue = (i / usableBins) * 240; // 蓝→绿→黄→红
      const lightness = 40 + (value / 255) * 30;
      ctx.fillStyle = `hsl(, 100%, %)`;
 
      const x = i * barWidth;
      const y = H - barHeight;
 
      // 绘制柱体(圆角矩形)
      ctx.beginPath();
      ctx.roundRect(x + 1, y, barWidth - 2, barHeight, [3, 3, 0, 0]);
      ctx.fill();
 
      // 绘制柱顶高光
      ctx.fillStyle = `hsla(, 100%, 90%, 0.8)`;
      ctx.fillRect(x + 1, y, barWidth - 2, 2);
    }
  }
 
  destroy() {
    this.stop();
    this._resizeObserver.disconnect();
  }
}

使用方式:

// 建立音频图
const source = audioCtx.createMediaElementSource(audio);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.8;
 
source.connect(analyser);
analyser.connect(audioCtx.destination);
 
// 启动可视化
const canvas = document.getElementById('spectrumCanvas');
const viz = new SpectrumVisualizer(analyser, canvas);
 
audio.addEventListener('playing', () => viz.start());
audio.addEventListener('pause',   () => viz.stop());
audio.addEventListener('ended',   () => viz.stop());

五、实战二:波形图(示波器)

波形图使用时域数据,展示音频信号的实时振幅变化。

class WaveformVisualizer {
  constructor(analyser, canvas) {
    this.analyser = analyser;
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.bufferLength = analyser.fftSize; // 注意:波形用 fftSize,不是 frequencyBinCount
    this.dataArray = new Uint8Array(this.bufferLength);
    this.animationId = null;
  }
 
  start() {
    const draw = () => {
      this.animationId = requestAnimationFrame(draw);
      this._drawFrame();
    };
    draw();
  }
 
  stop() {
    cancelAnimationFrame(this.animationId);
  }
 
  _drawFrame() {
    this.analyser.getByteTimeDomainData(this.dataArray);
 
    const { ctx, canvas } = this;
    const W = canvas.width;
    const H = canvas.height;
 
    // 清空
    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(0, 0, W, H);
 
    // 绘制中线(参考线)
    ctx.strokeStyle = 'rgba(255,255,255,0.1)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, H / 2);
    ctx.lineTo(W, H / 2);
    ctx.stroke();
 
    // 绘制波形
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#00ff88';
    ctx.shadowBlur = 8;
    ctx.shadowColor = '#00ff88';
    ctx.beginPath();
 
    const sliceWidth = W / this.bufferLength;
    let x = 0;
 
    for (let i = 0; i < this.bufferLength; i++) {
      // dataArray[i] 范围 0~255,128 对应零振幅
      const v = this.dataArray[i] / 128.0; // 归一化到 0~2
      const y = (v / 2) * H;              // 映射到画布高度
 
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
      x += sliceWidth;
    }
 
    ctx.lineTo(W, H / 2);
    ctx.stroke();
    ctx.shadowBlur = 0; // 重置阴影,避免影响后续绘制
  }
}

进阶:触发同步(Trigger Sync)

专业示波器会在波形的"过零点"处触发,确保波形稳定不漂移。我们也可以实现这个效果:

_findTriggerPoint(dataArray) {
  // 寻找从负到正的过零点,作为波形起始点
  const threshold = 128; // 0 振幅对应 128
  const halfLength = Math.floor(dataArray.length / 2);
 
  for (let i = 1; i < halfLength; i++) {
    if (dataArray[i - 1] < threshold && dataArray[i] >= threshold) {
      return i; // 找到上升过零点
    }
  }
  return 0; // 没找到则从头开始
}
 
_drawFrame() {
  this.analyser.getByteTimeDomainData(this.dataArray);
  const startIndex = this._findTriggerPoint(this.dataArray);
 
  // 从 startIndex 开始绘制,波形不再漂移
  const drawLength = Math.min(
    this.bufferLength - startIndex,
    this.bufferLength / 2
  );
  // ... 绘制逻辑同上,i 从 startIndex 开始
}

六、实战三:圆形频谱(最炫的那种)

把频谱数据映射到极坐标系,就能画出圆形频谱——这是音乐播放器中最常见的炫酷效果。

class CircularSpectrumVisualizer {
  constructor(analyser, canvas) {
    this.analyser = analyser;
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.bufferLength = analyser.frequencyBinCount;
    this.dataArray = new Uint8Array(this.bufferLength);
    this.animationId = null;
    this.rotation = 0; // 持续旋转角度
  }
 
  start() {
    const draw = () => {
      this.animationId = requestAnimationFrame(draw);
      this._drawFrame();
    };
    draw();
  }
 
  stop() { cancelAnimationFrame(this.animationId); }
 
  _drawFrame() {
    this.analyser.getByteFrequencyData(this.dataArray);
 
    const { ctx, canvas } = this;
    const W = canvas.width;
    const H = canvas.height;
    const cx = W / 2;  // 圆心 X
    const cy = H / 2;  // 圆心 Y
    const baseRadius = Math.min(W, H) * 0.25; // 基础半径
 
    // 清空(带拖尾)
    ctx.fillStyle = 'rgba(5, 5, 20, 0.2)';
    ctx.fillRect(0, 0, W, H);
 
    // 使用频谱的前 180 个 bin(覆盖人耳敏感频段)
    const usableBins = 180;
 
    // 上半圆和下半圆对称绘制(镜像效果)
    for (let mirror = 0; mirror < 2; mirror++) {
      for (let i = 0; i < usableBins; i++) {
        const value = this.dataArray[i];
        const barHeight = (value / 255) * baseRadius * 0.8;
 
        // 将 bin 索引映射到角度(加上旋转偏移)
        const angle = (i / usableBins) * Math.PI
          + (mirror === 1 ? Math.PI : 0)
          + this.rotation;
 
        // 极坐标转直角坐标
        const innerX = cx + Math.cos(angle) * baseRadius;
        const innerY = cy + Math.sin(angle) * baseRadius;
        const outerX = cx + Math.cos(angle) * (baseRadius + barHeight);
        const outerY = cy + Math.sin(angle) * (baseRadius + barHeight);
 
        // 颜色:低频暖色,高频冷色
        const hue = 260 - (i / usableBins) * 200;
        const alpha = 0.6 + (value / 255) * 0.4;
        ctx.strokeStyle = `hsla(, 100%, 60%, )`;
        ctx.lineWidth = (W / usableBins) * 1.2;
        ctx.lineCap = 'round';
 
        ctx.beginPath();
        ctx.moveTo(innerX, innerY);
        ctx.lineTo(outerX, outerY);
        ctx.stroke();
      }
    }
 
    // 绘制中心圆(随低频能量脉动)
    const bassEnergy = this._getBassEnergy();
    const pulseRadius = baseRadius * (0.85 + bassEnergy * 0.15);
 
    const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, pulseRadius);
    gradient.addColorStop(0, `rgba(120, 80, 255, )`);
    gradient.addColorStop(0.6, `rgba(60, 40, 180, )`);
    gradient.addColorStop(1, 'rgba(0,0,0,0)');
 
    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.arc(cx, cy, pulseRadius, 0, Math.PI * 2);
    ctx.fill();
 
    // 缓慢旋转
    this.rotation += 0.003;
  }
 
  // 计算低频(bass)能量,用于中心圆脉动
  _getBassEnergy() {
    const bassEnd = Math.floor(this.bufferLength * 0.05); // 前 5% 的 bin 是低频
    let sum = 0;
    for (let i = 0; i < bassEnd; i++) {
      sum += this.dataArray[i];
    }
    return (sum / bassEnd) / 255;
  }
}

七、实战四:频率能量分段提取

很多可视化效果需要把频谱分成几个大段(低频/中频/高频)分别处理,比如让封面图随低音鼓点跳动。

class FrequencyBandAnalyzer {
  constructor(analyser) {
    this.analyser = analyser;
    this.sampleRate = analyser.context.sampleRate;
    this.bufferLength = analyser.frequencyBinCount;
    this.dataArray = new Float32Array(this.bufferLength);
 
    // 定义频段范围(Hz)
    this.bands = {
      subBass:   { min: 20,   max: 60   }, // 次低频
      bass:      { min: 60,   max: 250  }, // 低频
      lowMid:    { min: 250,  max: 500  }, // 中低频
      mid:       { min: 500,  max: 2000 }, // 中频
      highMid:   { min: 2000, max: 4000 }, // 中高频
      presence:  { min: 4000, max: 6000 }, // 临场感
      brilliance:{ min: 6000, max: 20000}, // 高频
    };
  }
 
  // Hz 转 bin 索引
  _hzToBin(hz) {
    return Math.round(hz / (this.sampleRate / 2) * this.bufferLength);
  }
 
  // 计算某个频段的平均能量(dBFS)
  _getBandEnergy(minHz, maxHz) {
    this.analyser.getFloatFrequencyData(this.dataArray);
    const startBin = this._hzToBin(minHz);
    const endBin = Math.min(this._hzToBin(maxHz), this.bufferLength - 1);
 
    let sum = 0;
    const count = endBin - startBin + 1;
    for (let i = startBin; i <= endBin; i++) {
      // Float 频域数据单位是 dBFS(通常 -Infinity ~ 0)
      // 转换为线性幅度再平均,结果更准确
      sum += Math.pow(10, this.dataArray[i] / 20);
    }
    const avgAmplitude = sum / count;
    return 20 * Math.log10(avgAmplitude); // 转回 dBFS
  }
 
  // 获取所有频段的能量(归一化到 0~1)
  getAllBands() {
    const result = {};
    const minDb = -80;
    const maxDb = 0;
 
    for (const [name, { min, max }] of Object.entries(this.bands)) {
      const db = this._getBandEnergy(min, max);
      // 归一化
      result[name] = Math.max(0, Math.min(1, (db - minDb) / (maxDb - minDb)));
    }
    return result;
  }
}

应用:封面图随低音脉动

const bandAnalyzer = new FrequencyBandAnalyzer(analyser);
const albumArt = document.getElementById('albumArt');
 
function animateAlbumArt() {
  requestAnimationFrame(animateAlbumArt);
 
  const bands = bandAnalyzer.getAllBands();
  const bassEnergy = bands.bass;
 
  // 根据低频能量缩放封面图
  const scale = 1 + bassEnergy * 0.08;
  const brightness = 0.9 + bassEnergy * 0.3;
  const blur = bassEnergy > 0.7 ? (bassEnergy - 0.7) * 5 : 0;
 
  albumArt.style.transform = `scale()`;
  albumArt.style.filter = `brightness() blur(px)`;
}
 
audio.addEventListener('playing', animateAlbumArt);

八、实战五:WebGL 粒子系统可视化

Canvas 2D 对于复杂粒子系统性能有限,WebGL 可以轻松驾驭数万个粒子。这里用 Three.js 实现一个音频驱动的粒子系统:

import * as THREE from 'three';
 
class ParticleAudioVisualizer {
  constructor(analyser, container) {
    this.analyser = analyser;
    this.bufferLength = analyser.frequencyBinCount;
    this.dataArray = new Uint8Array(this.bufferLength);
 
    this._initThree(container);
    this._createParticles(8000);
    this._animate();
  }
 
  _initThree(container) {
    // 场景
    this.scene = new THREE.Scene();
 
    // 相机
    this.camera = new THREE.PerspectiveCamera(
      75,
      container.offsetWidth / container.offsetHeight,
      0.1,
      1000
    );
    this.camera.position.z = 80;
 
    // 渲染器
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setSize(container.offsetWidth, container.offsetHeight);
    this.renderer.setPixelRatio(devicePixelRatio);
    container.appendChild(this.renderer.domElement);
  }
 
  _createParticles(count) {
    const positions = new Float32Array(count * 3);
    const colors = new Float32Array(count * 3);
    const sizes = new Float32Array(count);
 
    for (let i = 0; i < count; i++) {
      // 球形分布
      const theta = Math.random() * Math.PI * 2;
      const phi = Math.acos(2 * Math.random() - 1);
      const r = 20 + Math.random() * 40;
 
      positions[i * 3]     = r * Math.sin(phi) * Math.cos(theta);
      positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
      positions[i * 3 + 2] = r * Math.cos(phi);
 
      colors[i * 3]     = Math.random();
      colors[i * 3 + 1] = Math.random() * 0.5;
      colors[i * 3 + 2] = 1.0;
 
      sizes[i] = Math.random() * 2 + 0.5;
    }
 
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color',    new THREE.BufferAttribute(colors, 3));
    geometry.setAttribute('size',     new THREE.BufferAttribute(sizes, 1));
 
    const material = new THREE.PointsMaterial({
      size: 0.8,
      vertexColors: true,
      transparent: true,
      opacity: 0.8,
      blending: THREE.AdditiveBlending, // 叠加混合,产生发光效果
      depthWrite: false,
    });
 
    this.particles = new THREE.Points(geometry, material);
    this.scene.add(this.particles);
 
    // 保存原始位置,用于恢复
    this._originalPositions = positions.slice();
  }
 
  _animate() {
    this.animationId = requestAnimationFrame(() => this._animate());
    this.analyser.getByteFrequencyData(this.dataArray);
 
    const positions = this.particles.geometry.attributes.position.array;
    const count = positions.length / 3;
 
    // 计算整体能量(驱动粒子扩散)
    let totalEnergy = 0;
    for (let i = 0; i < this.bufferLength; i++) {
      totalEnergy += this.dataArray[i];
    }
    const normalizedEnergy = totalEnergy / (this.bufferLength * 255);
 
    for (let i = 0; i < count; i++) {
      // 用粒子索引映射到对应的频率 bin
      const binIndex = Math.floor((i / count) * this.bufferLength);
      const freqValue = this.dataArray[binIndex] / 255;
 
      // 原始位置
      const ox = this._originalPositions[i * 3];
      const oy = this._originalPositions[i * 3 + 1];
      const oz = this._originalPositions[i * 3 + 2];
 
      // 根据频率能量向外扩散
      const expansionFactor = 1 + freqValue * 1.5;
      positions[i * 3]     = ox * expansionFactor;
      positions[i * 3 + 1] = oy * expansionFactor;
      positions[i * 3 + 2] = oz * expansionFactor;
    }
 
    this.particles.geometry.attributes.position.needsUpdate = true;
 
    // 整体旋转
    this.particles.rotation.y += 0.003 + normalizedEnergy * 0.01;
    this.particles.rotation.x += 0.001;
 
    this.renderer.render(this.scene, this.camera);
  }
 
  destroy() {
    cancelAnimationFrame(this.animationId);
    this.renderer.dispose();
  }
}

九、性能优化:让可视化丝滑运行

音频可视化是典型的高频渲染场景,性能优化至关重要。

Canvas 2D 性能优化

// 1. 使用 OffscreenCanvas 在 Worker 中渲染(Chrome 支持)
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('visualizer-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
 
// 2. 避免每帧创建新对象——复用 TypedArray
// 错误做法:
function draw() {
  const data = new Uint8Array(bufferLength); // 每帧 GC 压力
  analyser.getByteFrequencyData(data);
}
 
// 正确做法:在外部创建,复用
const data = new Uint8Array(bufferLength);
function draw() {
  analyser.getByteFrequencyData(data); // 复用同一个数组
}
 
// 3. 减少 Canvas 状态切换
// 错误做法:每个柱子单独设置 fillStyle
for (let i = 0; i < bins; i++) {
  ctx.fillStyle = `hsl(, 100%, 50%)`; // 每次都切换状态
  ctx.fillRect(...);
}
 
// 正确做法:按颜色批量绘制,或使用渐变
const gradient = ctx.createLinearGradient(0, 0, W, 0);
gradient.addColorStop(0, '#667eea');
gradient.addColorStop(1, '#764ba2');
ctx.fillStyle = gradient; // 只设置一次

降低数据分辨率(续)

// 将 1024 个 bin 降采样为 64 个显示柱
// 使用对数分组——低频细分,高频粗分(符合人耳感知)
function downsampleFrequencyLog(dataArray, targetBars) {
  const result = new Float32Array(targetBars);
  const binCount = dataArray.length;
 
  for (let i = 0; i < targetBars; i++) {
    // 对数映射:低频段分配更多柱子
    const startBin = Math.floor(Math.pow(binCount, i / targetBars));
    const endBin   = Math.floor(Math.pow(binCount, (i + 1) / targetBars));
 
    let sum = 0;
    const count = Math.max(1, endBin - startBin);
    for (let j = startBin; j < endBin && j < binCount; j++) {
      sum += dataArray[j];
    }
    result[i] = sum / count;
  }
  return result;
}
 
// 使用
const rawData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(rawData);
const bars = downsampleFrequencyLog(rawData, 64); // 64 根柱子

为什么要用对数分组而不是线性分组?因为人耳对频率的感知本身就是对数的——从 100Hz 到 200Hz 的感知跨度,和从 1000Hz 到 2000Hz 的感知跨度相同(都是一个八度)。线性分组会导致大量柱子堆积在高频段(视觉上没有信息),而低频段(最有表现力的部分)只有寥寥几根柱子。

will-change 与 CSS 层提升

/* 告知浏览器该元素会频繁变化,提前创建合成层 */
canvas {
  will-change: transform;
  /* 强制 GPU 合成,避免每帧触发重绘 */
  transform: translateZ(0);
}

暂停时停止渲染循环

// 页面不可见时自动暂停,节省资源
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    viz.stop();
  } else if (!audio.paused) {
    viz.start();
  }
});

十、实战六:完整的音乐播放器可视化组件

把前面所有内容整合成一个生产可用的完整组件,支持多种可视化模式切换:

class AudioVisualizer {
  constructor(audioEl, container) {
    this.audio = audioEl;
    this.container = container;
 
    // 创建 AudioContext 和分析器
    this.audioCtx = new AudioContext();
    this.analyser = this.audioCtx.createAnalyser();
    this.analyser.fftSize = 2048;
    this.analyser.smoothingTimeConstant = 0.8;
 
    // 连接音频图
    this.source = this.audioCtx.createMediaElementSource(audioEl);
    this.source.connect(this.analyser);
    this.analyser.connect(this.audioCtx.destination);
 
    // 创建画布
    this.canvas = document.createElement('canvas');
    this.canvas.style.cssText = `
      width: 100%; height: 100%;
      display: block; background: #0a0a1a;
    `;
    container.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');
 
    // 数据缓冲区
    this.bufferLength = this.analyser.frequencyBinCount;
    this.freqData = new Uint8Array(this.bufferLength);
    this.timeData = new Uint8Array(this.analyser.fftSize);
 
    // 状态
    this.mode = 'spectrum'; // 'spectrum' | 'waveform' | 'circular'
    this.animationId = null;
 
    // 峰值保持(频谱柱顶小横线)
    this.peakValues = new Float32Array(this.bufferLength).fill(0);
    this.peakDecay = 0.995; // 峰值衰减速率
 
    this._bindEvents();
    this._resize();
  }
 
  // ── 事件绑定 ───────────────────────────────────────────
  _bindEvents() {
    // 音频事件
    this.audio.addEventListener('play', async () => {
      if (this.audioCtx.state === 'suspended') {
        await this.audioCtx.resume();
      }
      this.start();
    });
    this.audio.addEventListener('pause',  () => this.stop());
    this.audio.addEventListener('ended',  () => this.stop());
 
    // 画布尺寸响应
    new ResizeObserver(() => this._resize()).observe(this.container);
 
    // 页面可见性
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.stop();
      } else if (!this.audio.paused) {
        this.start();
      }
    });
  }
 
  _resize() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.container.offsetWidth;
    const h = this.container.offsetHeight;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.scale(dpr, dpr);
    this._W = w;
    this._H = h;
  }
 
  // ── 渲染控制 ───────────────────────────────────────────
  start() {
    if (this.animationId) return;
    const loop = () => {
      this.animationId = requestAnimationFrame(loop);
      this._render();
    };
    loop();
  }
 
  stop() {
    cancelAnimationFrame(this.animationId);
    this.animationId = null;
  }
 
  setMode(mode) {
    this.mode = mode;
  }
 
  // ── 主渲染分发 ─────────────────────────────────────────
  _render() {
    switch (this.mode) {
      case 'spectrum':  this._drawSpectrum();  break;
      case 'waveform':  this._drawWaveform();  break;
      case 'circular':  this._drawCircular();  break;
    }
  }
 
  // ── 模式一:频谱柱状图(含峰值保持)─────────────────────
  _drawSpectrum() {
    this.analyser.getByteFrequencyData(this.freqData);
 
    const { ctx } = this;
    const W = this._W, H = this._H;
 
    // 背景渐隐(拖尾效果)
    ctx.fillStyle = 'rgba(10, 10, 26, 0.25)';
    ctx.fillRect(0, 0, W, H);
 
    const BAR_COUNT = 80;
    const bars = downsampleFrequencyLog(this.freqData, BAR_COUNT);
    const barWidth = (W / BAR_COUNT) * 0.8;
    const gap      = (W / BAR_COUNT) * 0.2;
 
    for (let i = 0; i < BAR_COUNT; i++) {
      const value    = bars[i] / 255;
      const barH     = value * H * 0.9;
      const x        = i * (barWidth + gap);
      const y        = H - barH;
 
      // 峰值保持与衰减
      if (value > this.peakValues[i]) {
        this.peakValues[i] = value;
      } else {
        this.peakValues[i] *= this.peakDecay;
      }
 
      // 柱体渐变色
      const gradient = ctx.createLinearGradient(0, H, 0, y);
      gradient.addColorStop(0,   `hsla(, 90%, 55%, 0.9)`);
      gradient.addColorStop(0.6, `hsla(, 100%, 65%, 0.8)`);
      gradient.addColorStop(1,   `hsla(, 100%, 80%, 0.6)`);
 
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.roundRect(x, y, barWidth, barH, [3, 3, 0, 0]);
      ctx.fill();
 
      // 峰值线(小横线)
      const peakY = H - this.peakValues[i] * H * 0.9 - 3;
      ctx.fillStyle = `hsla(, 100%, 85%, 0.9)`;
      ctx.fillRect(x, peakY, barWidth, 2);
    }
  }
 
  // ── 模式二:波形图(含触发同步)─────────────────────────
  _drawWaveform() {
    this.analyser.getByteTimeDomainData(this.timeData);
 
    const { ctx } = this;
    const W = this._W, H = this._H;
 
    ctx.fillStyle = 'rgba(10, 10, 26, 0.3)';
    ctx.fillRect(0, 0, W, H);
 
    // 找过零点触发
    const trigger = this._findTrigger(this.timeData);
    const drawLen = Math.floor(this.timeData.length / 2);
 
    // 双线波形(镜像,更有视觉冲击力)
    for (let mirror = 0; mirror < 2; mirror++) {
      ctx.beginPath();
      ctx.lineWidth   = 2;
      ctx.strokeStyle = mirror === 0 ? '#00e5ff' : '#7c4dff';
      ctx.shadowBlur  = 12;
      ctx.shadowColor = mirror === 0 ? '#00e5ff' : '#7c4dff';
 
      for (let i = 0; i < drawLen; i++) {
        const idx = trigger + i;
        if (idx >= this.timeData.length) break;
 
        const v = (this.timeData[idx] - 128) / 128; // -1 ~ 1
        const x = (i / drawLen) * W;
        // mirror === 1 时翻转 Y 轴
        const y = H / 2 + v * (H / 2 - 20) * (mirror === 1 ? -1 : 1);
 
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      }
      ctx.stroke();
    }
    ctx.shadowBlur = 0;
  }
 
  _findTrigger(data) {
    const mid = 128;
    for (let i = 1; i < data.length / 2; i++) {
      if (data[i - 1] < mid && data[i] >= mid) return i;
    }
    return 0;
  }
 
  // ── 模式三:圆形频谱(含低频脉动)──────────────────────
  _drawCircular() {
    this.analyser.getByteFrequencyData(this.freqData);
 
    const { ctx } = this;
    const W = this._W, H = this._H;
    const cx = W / 2, cy = H / 2;
    const R  = Math.min(W, H) * 0.28;
 
    ctx.fillStyle = 'rgba(10, 10, 26, 0.18)';
    ctx.fillRect(0, 0, W, H);
 
    const BARS    = 128;
    const bars    = downsampleFrequencyLog(this.freqData, BARS);
    const bassEnergy = bars.slice(0, 4).reduce((a, b) => a + b, 0) / (4 * 255);
 
    // 脉动圆环
    const pulseR = R * (0.92 + bassEnergy * 0.12);
    ctx.beginPath();
    ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(100, 80, 255, )`;
    ctx.lineWidth = 2 + bassEnergy * 4;
    ctx.stroke();
 
    // 频谱条(上下镜像)
    for (let mirror = 0; mirror < 2; mirror++) {
      for (let i = 0; i < BARS; i++) {
        const value  = bars[i] / 255;
        const barLen = value * R * 0.7;
        const angle  = (i / BARS) * Math.PI
          + (mirror === 1 ? Math.PI : 0)
          + (Date.now() * 0.0003); // 缓慢旋转
 
        const x1 = cx + Math.cos(angle) * pulseR;
        const y1 = cy + Math.sin(angle) * pulseR;
        const x2 = cx + Math.cos(angle) * (pulseR + barLen);
        const y2 = cy + Math.sin(angle) * (pulseR + barLen);
 
        const hue = 200 + (i / BARS) * 160;
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.strokeStyle = `hsla(, 100%, 65%, )`;
        ctx.lineWidth   = Math.max(1, (W / BARS) * 0.6);
        ctx.lineCap     = 'round';
        ctx.stroke();
      }
    }
 
    // 中心光晕
    const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, R * 0.5);
    grd.addColorStop(0,   `rgba(120, 80, 255, )`);
    grd.addColorStop(0.5, `rgba(60, 40, 200, )`);
    grd.addColorStop(1,   'rgba(0,0,0,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(cx, cy, R * 0.5, 0, Math.PI * 2);
    ctx.fill();
  }
 
  // ── 销毁 ───────────────────────────────────────────────
  async destroy() {
    this.stop();
    await this.audioCtx.close();
    this.canvas.remove();
  }
}

配套 HTML + 模式切换 UI

<div class="player-container">
  <!-- 可视化画布区域 -->
  <div id="vizContainer" style="width:100%; height:300px;"></div>
 
  <!-- 音频元素(隐藏原生控件) -->
  <audio id="audio" src="music.mp3" preload="metadata"></audio>
 
  <!-- 自定义控件 -->
  <div class="controls">
    <button id="playBtn">▶</button>
    <div class="progress-bar" id="progressBar">
      <div class="progress-fill" id="progressFill"></div>
    </div>
 
    <!-- 可视化模式切换 -->
    <div class="viz-modes">
      <button class="mode-btn active" data-mode="spectrum">频谱</button>
      <button class="mode-btn" data-mode="waveform">波形</button>
      <button class="mode-btn" data-mode="circular">圆形</button>
    </div>
  </div>
</div>
 
<script>
  const audio = document.getElementById('audio');
  const viz = new AudioVisualizer(audio, document.getElementById('vizContainer'));
 
  // 播放控制
  document.getElementById('playBtn').addEventListener('click', async () => {
    if (audio.paused) {
      await audio.play();
      document.getElementById('playBtn').textContent = '⏸';
    } else {
      audio.pause();
      document.getElementById('playBtn').textContent = '▶';
    }
  });
 
  // 进度条
  audio.addEventListener('timeupdate', () => {
    const pct = (audio.currentTime / audio.duration) * 100 || 0;
    document.getElementById('progressFill').style.width = `%`;
  });
 
  // 模式切换
  document.querySelectorAll('.mode-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      viz.setMode(btn.dataset.mode);
    });
  });
</script>

十一、本章知识图谱

流程图画布 · 115%
Mermaid 流程图加载中...

小结

音频可视化是 Web 音频开发中最有成就感的部分——它把抽象的数字信号变成人眼可以感知的视觉艺术。从理解 FFT 的数学本质,到掌握 AnalyserNode 的四种数据接口,再到用 Canvas 2D 实现频谱、波形、圆形频谱,最后用 WebGL 驾驭粒子系统,每一层都建立在对上一层的深刻理解之上。

真正优秀的音频可视化,不只是"数据驱动的动画",而是让视觉节奏与音乐节奏产生共鸣——这需要你对音频信号的频率特性有足够的感知,知道哪个频段代表鼓点、哪个频段代表人声、哪个频段代表高帽,然后把这些信息映射到最合适的视觉维度上。

下一章我们将深入音频处理与效果器——均衡器、压缩器、混响、失真、合唱……把第七章介绍的各种 Web Audio 节点真正用起来,构建一套专业级的音频效果链,让你的播放器不只是"能听",而是"好听"。