音频可视化
音频可视化是将音频信号的频谱或波形数据通过 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 |
频谱柱状图
实现原理
- 从
AnalyserNode获取频率数据 - 将数据映射到柱状图的高度
- 在 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();下一节
继续学习:无障碍原则