Skip to content

音频可视化

音频可视化是将音频信号的频谱或波形数据通过 Canvas 或 SVG 图形展示出来的技术。通过 AnalyserNode 获取音频的频率数据和时域数据,结合 Canvas 绘图 API,可以创建出各种视觉效果,如频谱柱状图、波形图、圆形频谱等。本节将详细介绍如何实现 Web Audio API 的音频可视化。

前置知识

阅读本节前,建议先了解:音频播放与控制

基础概念

音频可视化的核心是 AnalyserNode,它从音频流中提取频率数据(频域)和时域数据(波形),供可视化使用。

两种数据类型

数据类型方法说明应用场景
频域数据getByteFrequencyData()各频率的强度(0~255)频谱柱状图、颜色映射
时域数据getByteTimeDomainData()波形振幅(0~255,128 为中线)波形图、示波器

AnalyserNode 配置

javascript
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;             // FFT 大小(2 的幂)
analyser.smoothingTimeConstant = 0.8; // 平滑系数(0~1)
属性说明推荐值
fftSize分析窗口大小2048(1024 频率桶)
frequencyBinCount频率桶数量 = fftSize / 2(只读)1024
smoothingTimeConstant平滑度(越高越平滑)0.8

频谱柱状图

实现原理

  1. AnalyserNode 获取频率数据
  2. 将数据映射到柱状图的高度
  3. 在 Canvas 上绘制彩色柱状图
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>音频可视化 - 频谱柱状图</title>
  <style>
    body { font-family: sans-serif; padding: 20px; text-align: center; }
    canvas { border: 1px solid #ccc; display: block; margin: 20px auto; }
    .controls { margin: 15px 0; }
    button { padding: 10px 20px; margin: 5px; cursor: pointer; }
  </style>
</head>
<body>
  <h1>音频可视化 - 频谱柱状图</h1>

  <div class="controls">
    <button id="btnStart">开始播放</button>
    <button id="btnStop">停止</button>
  </div>

  <canvas id="visualizer" width="600" height="300"></canvas>

  <script>
    let audioCtx = null;
    let analyser = null;
    let oscillator = null;
    let gainNode = null;
    let animationId = null;

    const canvas = document.getElementById('visualizer');
    const ctx = canvas.getContext('2d');
    const WIDTH = canvas.width;
    const HEIGHT = canvas.height;

    /**
     * 初始化音频上下文
     */
    async function initAudio() {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      if (audioCtx.state === 'suspended') {
        await audioCtx.resume();
      }

      // 创建节点
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 256; // 128 个频率桶
      analyser.smoothingTimeConstant = 0.8;

      gainNode = audioCtx.createGain();
      gainNode.gain.value = 0.3;

      // 连接:振荡器 → 增益 → 分析器 → 输出
      oscillator = audioCtx.createOscillator();
      oscillator.type = 'sawtooth';
      oscillator.frequency.setValueAtTime(220, audioCtx.currentTime);

      // 添加频率变化,让频谱更丰富
      const lfo = audioCtx.createOscillator();
      const lfoGain = audioCtx.createGain();
      lfo.frequency.value = 2; // 低频振荡
      lfoGain.gain.value = 100; // 调制深度
      lfo.connect(lfoGain);
      lfoGain.connect(oscillator.frequency);
      lfo.start();

      oscillator.connect(gainNode);
      gainNode.connect(analyser);
      analyser.connect(audioCtx.destination);

      oscillator.start();
    }

    /**
     * 绘制频谱柱状图
     */
    function drawSpectrum() {
      animationId = requestAnimationFrame(drawSpectrum);

      const bufferLength = analyser.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      analyser.getByteFrequencyData(dataArray);

      // 清除画布
      ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
      ctx.fillRect(0, 0, WIDTH, HEIGHT);

      const barWidth = (WIDTH / bufferLength) * 2.5;
      let x = 0;

      for (let i = 0; i < bufferLength; i++) {
        const barHeight = (dataArray[i] / 255) * HEIGHT;

        // 使用 HSL 颜色,色相随频率变化
        const hue = (i / bufferLength) * 360;
        ctx.fillStyle = `hsl(${hue}, 80%, 55%)`;
        ctx.fillRect(x, HEIGHT - barHeight, barWidth - 1, barHeight);

        x += barWidth;
        if (x > WIDTH) break;
      }
    }

    // 开始
    document.getElementById('btnStart').addEventListener('click', async () => {
      await initAudio();
      drawSpectrum();
    });

    // 停止
    document.getElementById('btnStop').addEventListener('click', () => {
      if (animationId) {
        cancelAnimationFrame(animationId);
        animationId = null;
      }
      if (oscillator) {
        oscillator.stop();
        oscillator = null;
      }
      ctx.clearRect(0, 0, WIDTH, HEIGHT);
    });
  </script>
</body>
</html>

波形图

实现原理

使用 getByteTimeDomainData() 获取时域数据,绘制连续的波形曲线。

javascript
/**
 * 绘制波形图
 */
function drawWaveform() {
  animationId = requestAnimationFrame(drawWaveform);

  const bufferLength = analyser.fftSize;
  const dataArray = new Uint8Array(bufferLength);
  analyser.getByteTimeDomainData(dataArray);

  // 清除画布
  ctx.fillStyle = '#1a237e';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  // 绘制中线
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, HEIGHT / 2);
  ctx.lineTo(WIDTH, HEIGHT / 2);
  ctx.stroke();

  // 绘制波形
  ctx.lineWidth = 2;
  ctx.strokeStyle = '#4285f4';
  ctx.beginPath();

  const sliceWidth = WIDTH / bufferLength;
  let x = 0;

  for (let i = 0; i < bufferLength; i++) {
    const v = dataArray[i] / 128.0; // 归一化(128 为中线)
    const y = (v * HEIGHT) / 2;

    if (i === 0) {
      ctx.moveTo(x, y);
    } else {
      ctx.lineTo(x, y);
    }

    x += sliceWidth;
  }

  ctx.stroke();

  // 绘制发光效果
  ctx.lineWidth = 6;
  ctx.strokeStyle = 'rgba(66, 133, 244, 0.3)';
  ctx.stroke();
}

圆形频谱

实现原理

将频率数据映射到圆形路径上,创建视觉效果丰富的圆形频谱。

javascript
/**
 * 绘制圆形频谱
 */
function drawCircularSpectrum() {
  animationId = requestAnimationFrame(drawCircularSpectrum);

  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);
  analyser.getByteFrequencyData(dataArray);

  // 清除画布
  ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  const centerX = WIDTH / 2;
  const centerY = HEIGHT / 2;
  const radius = 80;
  const bars = Math.min(bufferLength, 180);

  for (let i = 0; i < bars; i++) {
    const angle = (i / bars) * Math.PI * 2;
    const value = dataArray[i] / 255;
    const barHeight = value * 100 + 5;

    const x1 = centerX + Math.cos(angle) * radius;
    const y1 = centerY + Math.sin(angle) * radius;
    const x2 = centerX + Math.cos(angle) * (radius + barHeight);
    const y2 = centerY + Math.sin(angle) * (radius + barHeight);

    const hue = (i / bars) * 360;
    ctx.strokeStyle = `hsl(${hue}, 80%, 55%)`;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();
  }

  // 中心圆
  ctx.beginPath();
  ctx.arc(centerX, centerY, radius - 2, 0, Math.PI * 2);
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
  ctx.lineWidth = 1;
  ctx.stroke();
}

完整示例:多模式可视化

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>音频可视化 - 多模式</title>
  <style>
    body { font-family: sans-serif; padding: 20px; text-align: center; }
    canvas { border: 1px solid #ccc; display: block; margin: 20px auto; background: #0d1117; }
    .controls { margin: 15px 0; }
    button { padding: 8px 16px; margin: 5px; cursor: pointer; background: #4285f4; color: white; border: none; border-radius: 4px; }
    button:hover { background: #34a853; }
    button.active { background: #ea4335; }
  </style>
</head>
<body>
  <h1>音频可视化 - 多模式</h1>

  <div class="controls">
    <button id="btnStart">开始</button>
    <button id="btnStop">停止</button>
  </div>
  <div class="controls">
    <button class="mode active" data-mode="bars">柱状图</button>
    <button class="mode" data-mode="wave">波形图</button>
    <button class="mode" data-mode="circular">圆形</button>
    <button class="mode" data-mode="mirror">镜像</button>
  </div>

  <canvas id="visualizer" width="600" height="350"></canvas>

  <script>
    let audioCtx = null;
    let analyser = null;
    let oscillator = null;
    let animationId = null;
    let currentMode = 'bars';

    const canvas = document.getElementById('visualizer');
    const ctx = canvas.getContext('2d');
    const W = canvas.width;
    const H = canvas.height;

    // 模式切换
    document.querySelectorAll('.mode').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.mode').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        currentMode = btn.dataset.mode;
      });
    });

    async function initAudio() {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      if (audioCtx.state === 'suspended') await audioCtx.resume();

      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 512;
      analyser.smoothingTimeConstant = 0.85;

      const gain = audioCtx.createGain();
      gain.gain.value = 0.2;

      oscillator = audioCtx.createOscillator();
      oscillator.type = 'sawtooth';

      // 使用 LFO 让声音更丰富
      const lfo = audioCtx.createOscillator();
      const lfoGain = audioCtx.createGain();
      lfo.frequency.value = 0.5;
      lfoGain.gain.value = 150;
      lfo.connect(lfoGain);
      lfoGain.connect(oscillator.frequency);
      lfo.start();

      // 添加谐波
      const osc2 = audioCtx.createOscillator();
      osc2.type = 'square';
      osc2.frequency.value = 330;
      const gain2 = audioCtx.createGain();
      gain2.gain.value = 0.1;

      oscillator.connect(gain);
      osc2.connect(gain2);
      gain.connect(analyser);
      gain2.connect(analyser);
      analyser.connect(audioCtx.destination);

      oscillator.start();
      osc2.start();
    }

    function draw() {
      animationId = requestAnimationFrame(draw);

      const bufLen = analyser.frequencyBinCount;
      const freqData = new Uint8Array(bufLen);
      const timeData = new Uint8Array(bufLen);
      analyser.getByteFrequencyData(freqData);
      analyser.getByteTimeDomainData(timeData);

      ctx.fillStyle = 'rgba(13, 17, 23, 0.2)';
      ctx.fillRect(0, 0, W, H);

      switch (currentMode) {
        case 'bars': drawBars(freqData, bufLen); break;
        case 'wave': drawWave(timeData, bufLen); break;
        case 'circular': drawCircular(freqData, bufLen); break;
        case 'mirror': drawMirror(freqData, bufLen); break;
      }
    }

    // === 柱状图 ===
    function drawBars(data, len) {
      const barW = (W / len) * 2.5;
      let x = 0;
      for (let i = 0; i < len; i++) {
        const h = (data[i] / 255) * H;
        const hue = (i / len) * 300;
        ctx.fillStyle = `hsl(${hue}, 80%, 55%)`;
        ctx.fillRect(x, H - h, barW - 1, h);
        x += barW;
        if (x > W) break;
      }
    }

    // === 波形图 ===
    function drawWave(data, len) {
      ctx.lineWidth = 2;
      ctx.strokeStyle = '#00e676';
      ctx.beginPath();
      const sw = W / len;
      for (let i = 0; i < len; i++) {
        const v = data[i] / 128;
        const y = (v * H) / 2;
        if (i === 0) ctx.moveTo(0, y);
        else ctx.lineTo(i * sw, y);
      }
      ctx.stroke();
      // 发光
      ctx.lineWidth = 6;
      ctx.strokeStyle = 'rgba(0, 230, 118, 0.2)';
      ctx.stroke();
    }

    // === 圆形频谱 ===
    function drawCircular(data, len) {
      const cx = W / 2, cy = H / 2, r = 80;
      const bars = Math.min(len, 180);
      for (let i = 0; i < bars; i++) {
        const angle = (i / bars) * Math.PI * 2;
        const val = data[i] / 255;
        const bh = val * 100 + 5;
        const x1 = cx + Math.cos(angle) * r;
        const y1 = cy + Math.sin(angle) * r;
        const x2 = cx + Math.cos(angle) * (r + bh);
        const y2 = cy + Math.sin(angle) * (r + bh);
        ctx.strokeStyle = `hsl(${(i / bars) * 360}, 80%, 55%)`;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.stroke();
      }
    }

    // === 镜像频谱 ===
    function drawMirror(data, len) {
      const barW = (W / len) * 2.5;
      let x = 0;
      const midY = H / 2;
      for (let i = 0; i < len; i++) {
        const h = (data[i] / 255) * midY;
        const hue = (i / len) * 300;
        // 上半部分
        ctx.fillStyle = `hsl(${hue}, 80%, 55%)`;
        ctx.fillRect(x, midY - h, barW - 1, h);
        // 下半部分(镜像)
        ctx.fillStyle = `hsl(${hue}, 60%, 40%)`;
        ctx.fillRect(x, midY, barW - 1, h);
        x += barW;
        if (x > W) break;
      }
      // 中线
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(0, midY);
      ctx.lineTo(W, midY);
      ctx.stroke();
    }

    document.getElementById('btnStart').addEventListener('click', async () => {
      await initAudio();
      draw();
    });

    document.getElementById('btnStop').addEventListener('click', () => {
      cancelAnimationFrame(animationId);
      oscillator.stop();
      oscillator = null;
      ctx.clearRect(0, 0, W, H);
    });
  </script>
</body>
</html>

注意事项

1. fftSize 与频率桶的关系

javascript
analyser.fftSize = 2048;
const frequencyBinCount = analyser.frequencyBinCount; // 1024
// frequencyBinCount = fftSize / 2

// 频率桶覆盖的范围:0 ~ sampleRate / 2
// 每个桶的频率宽度:sampleRate / fftSize
// 44100Hz / 2048 ≈ 21.5Hz 每桶

2. 使用真实音频源

上面的示例使用振荡器作为音源,实际项目中通常使用 <audio> 元素或文件:

javascript
// 使用 <audio> 元素作为音源
const audio = document.querySelector('audio');
const source = audioCtx.createMediaElementSource(audio);
const analyser = audioCtx.createAnalyser();

source.connect(analyser);
analyser.connect(audioCtx.destination);

audio.play();

// 可视化
function draw() {
  requestAnimationFrame(draw);
  const data = new Uint8Array(analyser.frequencyBinCount);
  analyser.getByteFrequencyData(data);
  // ... 绘制 ...
}

3. 性能考虑

javascript
// 不好的做法:每帧创建新的 Uint8Array
function draw() {
  const data = new Uint8Array(analyser.frequencyBinCount); // 每帧分配内存
  analyser.getByteFrequencyData(data);
}

// 好的做法:复用数组
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const timeArray = new Uint8Array(analyser.fftSize);

function draw() {
  analyser.getByteFrequencyData(dataArray);
  analyser.getByteTimeDomainData(timeArray);
  // ... 使用预分配的数组 ...
}

最佳实践

1. 可视化模式封装

javascript
const Visualizers = {
  bars(ctx, data, w, h) {
    const barW = (w / data.length) * 2.5;
    let x = 0;
    for (let i = 0; i < data.length; i++) {
      const bh = (data[i] / 255) * h;
      ctx.fillStyle = `hsl(${(i / data.length) * 300}, 80%, 55%)`;
      ctx.fillRect(x, h - bh, barW - 1, bh);
      x += barW;
      if (x > w) break;
    }
  },

  wave(ctx, data, w, h) {
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#00e676';
    ctx.beginPath();
    for (let i = 0; i < data.length; i++) {
      const v = data[i] / 128;
      const y = (v * h) / 2;
      const x = (i / data.length) * w;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }
};

// 切换可视化模式
let mode = 'bars';
function render() {
  analyser.getByteFrequencyData(freqData);
  analyser.getByteTimeDomainData(timeData);
  ctx.fillStyle = 'rgba(0,0,0,0.2)';
  ctx.fillRect(0, 0, W, H);
  if (mode === 'bars') Visualizers.bars(ctx, freqData, W, H);
  else Visualizers.wave(ctx, timeData, W, H);
  requestAnimationFrame(render);
}

2. 响应式 Canvas

javascript
function resizeCanvas() {
  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.getBoundingClientRect();
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;
  ctx.scale(dpr, dpr);
}

window.addEventListener('resize', resizeCanvas);
resizeCanvas();

下一节

继续学习:无障碍原则

参考链接