MiMo TTS Studio 的音频播放器里有一个波形显示组件——不是用任何图表库,而是从零手写 Canvas 渲染。本文完整拆解:从 ArrayBuffer 解码到 200 条 RMS 柱状图,再到 Retina 高清适配和点击跳转。


Q1:整体渲染流程是怎样的?

A: 数据流是一条清晰的流水线:

1
2
3
4
5
6
7
8
9
10
11
音频文件(file:// 或 HTTP URL)
↓
fetchAudioBuffer() → ArrayBuffer(原始字节)
↓
AudioContext.decodeAudioData() → AudioBuffer(解码后的 PCM 数据)
↓
降采样 + RMS 计算 → [0.12, 0.45, 0.33, ..., 0.08] (200 个浮点数)
↓
归一化 → [0.27, 1.00, 0.73, ..., 0.18] (最大值 = 1)
↓
Canvas drawWaveform() → 柱状图绘制到屏幕

每一步都是纯原生 API,没有引入任何第三方库。


Q2:第一步——怎么把音频文件变成可处理的数据?

A: 这是整个流水线最关键的一步,因为 Electron 环境下音频来源有两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// WaveformDisplay.vue — 获取音频 ArrayBuffer

async function fetchAudioBuffer(src) {
if (!src) return null;

// ===== 情况 A: file:// 协议(Electron 本地文件)=====
if (src.startsWith('file://') || src.startsWith('/')) {
if (window.require) { // Electron 环境
// 不能直接 fetch file:// → 通过 IPC 让主进程读文件转 base64
const { ipc } = await import('@/utils/ipcRenderer');
const { ipcApiRoute } = await import('@/api');
if (ipc?.invoke) {
const base64 = await ipc.invoke(
ipcApiRoute.readFileAsBase64,
{ filePath: src.replace('file://', '') }
);
if (base64) {
// base64 字符串 → Uint8Array → ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
}
}
}

// ===== 情况 B: HTTP/HTTPS URL(普通网络请求)=====
try {
const response = await fetch(src);
return await response.arrayBuffer();
} catch (e) {
console.warn('[Waveform] Failed to fetch audio:', e.message);
return null;
}
}

为什么 file:// 要特殊处理?

浏览器出于安全原因,不允许 JavaScript 直接通过 fetch() 读取本地文件(即使是在 Electron 的渲染进程里)。所以需要绕道:

  1. 前端把路径发给主进程 IPC
  2. 主进程用 fs.readFileSync 读文件
  3. 转 Base64 返回给前端
  4. 前端解码回二进制

这比看起来要高效——因为 TTS 生成的音频文件通常只有几百 KB 到几 MB。


Q3:第二步——AudioContext 怎么解码 PCM 数据?

A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async function analyzeAudio(src) {
isAnalyzing.value = true;

try {
// Step 1: 获取原始字节数组
const arrayBuffer = await fetchAudioBuffer(src);
if (!arrayBuffer) return;

// Step 2: 创建 / 复用 AudioContext
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}

// Step 3: 解码为 AudioBuffer(PCM 数据)
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

// Step 4: 取出左声道数据(如果是立体声)
const channelData = audioBuffer.getChannelData(0); // Float32Array

// Step 5: 降采样为 200 个数据点
const samples = 200; // 最终柱数
const blockSize = Math.floor(channelData.length / samples);
const data = [];

for (let i = 0; i < samples; i++) {
let sum = 0;
// 对每个块内的所有采样点求绝对值的平均(RMS)
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(channelData[i * blockSize + j]);
}
data.push(sum / blockSize); // 该柱的 RMS 平均振幅
}

// Step 6: 归一化到 [0, 1]
const max = Math.max(...data, 0.01); // 防止除以 0
waveformData.value = data.map(v => v / max);

} catch (e) {
console.warn('[Waveform] Failed:', e.message);
waveformData.value = [];
} finally {
isAnalyzing.value = false;
}
}

关键概念解释

概念 含义 为什么重要
AudioBuffer 解码后的 PCM 音频,包含一个或多个声道的数据 Web Audio API 标准格式
getChannelData(0) 取左声道的 Float32Array(范围 [-1, 1]) 单声道图只需要一个通道
RMS(Root Mean Square)均方根 sqrt(mean(x²)),这里简化为 mean(abs(x)) 反映”音量感”,比单纯取峰值更准确
降采样 把 44100+ 个点压缩到 200 个 屏幕上画不下那么多柱子
归一化 除以最大值,映射到 [0, 1] 让无论音量大小都能填满画布高度

一段 3 秒的音频会生成多少数据?

1
2
3
原始采样: 44100 Hz × 3 秒 = 132,300 个 Float32 值
降采样后: 200 个浮点数(每个代表 ~661 个采样的 RMS)
内存占用: 200 × 8 bytes = 1.6 KB(微不足道)

Q4:第三步——Canvas 怎么绘制柱状图?

A: 绘制函数是整个组件最核心的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function drawWaveform() {
const canvas = canvasRef.value;
if (!canvas) return;

const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();

// === Retina 适配 ===
const dpr = window.devicePixelRatio || 1; // Mac Retina = 2, 普通 = 1
canvas.width = rect.width * dpr; // 物理像素 = CSS 像素 × DPR
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr); // 缩放绘图上下文

const width = rect.width; // CSS 宽度
const height = rect.height; // CSS 高度
const data = waveformData.value;

// 清除上一帧
ctx.clearRect(0, 0, width, height);
if (!data.length) return;

// 计算柱子参数
const barWidth = width / data.length; // 每根柱子的宽度
const gap = 1; // 柱间距(像素)
const midY = height / 2; // 中线(对称绘制)
const maxBarHeight = height * 0.85; // 最大柱高(留一点边距)

// 当前播放位置比例
const currentTimeRatio = props.duration > 0
? props.currentTime / props.duration : 0;

// 逐柱绘制
for (let i = 0; i < data.length; i++) {
const x = i * barWidth;
const barH = Math.max(2, data[i] * maxBarHeight); // 最小 2px,避免不可见
const ratio = i / data.length;

// 颜色:已播放部分 vs 未播放部分
if (ratio < currentTimeRatio) {
ctx.fillStyle = 'var(--accent); // 已播:主题强调色(蓝色)
} else {
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
ctx.fillStyle = isDark ? '#3a4150' : '#c4ccd8'; // 未播:暗灰/浅灰
}

// 绘制柱子(以中线对称,上下各一半)
ctx.fillRect(
x + gap / 2, // x 起点(留半个 gap)
midY - barH / 2, // y 起点(中线往上偏移半高)
Math.max(1, barWidth - gap), // 宽度(减去 gap)
barH // 高度
);
}
}

视觉效果示意

1
2
3
4
5
6
7
未播放部分(灰色)     已播放部分(蓝色)     播放指针
┌──┬──┬──┬──┬──┬──┬──┬─█─┬─█─┬─█─┬─█─┐
│ │ │ │ │ │ │ │ │██│██│██│██│
│ │ │ │ │ │ │ │ │██│██│██│██│
│ │ │ │ │ │ │ │ │██│██│██│██│
└──┴──┴──┴──┴──┴──┴──┴─█─┴─█─┴─█─┴─█─┘
↑ midY(中线对称)

Q5:Retina 屏幕适配是怎么做的?不处理会怎样?

A: Retina 屏幕(DPR=2)的物理像素是 CSS 像素的 2 倍。如果不处理:

1
2
3
4
5
6
7
8
9
10
11
❌ 不适配 DPR:
Canvas CSS 尺寸: 400×48
Canvas 实际像素: 400×48(默认 1x)
Retina 显示: 每个物理像素要显示 2×2 的内容
结果: 图像模糊、柱子边缘锯齿严重

✅ 适配 DPR 后:
Canvas CSS 尺寸: 400×48
Canvas 实际像素: 800×96(= 400×48 × DPR 2)
ctx.scale(2, 2) → 所有绘图坐标自动放大 2 倍
Retina 显示: 清晰锐利

核心代码就 4 行:

1
2
3
4
5
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr; // 设置物理像素宽度
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr); // 缩放坐标系
// 之后所有绘图代码都不需要改——用的还是 CSS 坐标

devicePixelRatio 在不同设备上的值:

  • 普通显示器: 1
  • MacBook Pro Retina: 2
  • 部分 iPhone: 3
  • 4K 显示器缩放 200% 时: 2

Q6:点击跳转是怎么实现的?

A: 波形区域可以点击来跳转到对应播放位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="waveform-container" ref="containerRef">
<canvas ref="canvasRef" @click="handleClick"></canvas>
<!-- 播放指针 -->
<div class="waveform-playhead" :style="{ left: playheadPercent + '%' }"></div>
</div>
</template>

<script setup>
function handleClick(e) {
if (!props.duration) return; // 没有音频时不响应

const rect = canvasRef.value.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width; // 点击位置占比
emit('seek', ratio * props.duration); // 发送 seek 事件
}
</script>

父组件接收事件:

1
2
3
4
5
6
7
<!-- 在 MiniPlayer.vue 或父组件中 -->
<WaveformDisplay
:audio-src="currentAudioPath"
:current-time="currentTime"
:duration="duration"
@seek="(time) => { audio.currentTime = time }"
/>

交互逻辑:

1
2
3
4
5
6
7
用户在波形的 60% 位置点击
↓
handleClick → ratio = 0.6
↓ emit('seek', duration × 0.6)
↓ 父组件收到 → audio.currentTime = 新位置
↓ 播放器跳转 + playheadPercent 自动跟随更新
↓ watch 触发 → drawWaveform() 重绘 → 蓝色/灰色分界点移动

Q7:ResizeObserver 是做什么的?窗口大小变了怎么办?

A: 当容器尺寸变化时(比如侧边栏折叠、窗口拖拽调整),Canvas 需要重新计算尺寸并重绘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
onMounted(() => {
// 监听容器尺寸变化
if (containerRef.value && window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (waveformData.value.length) {
requestAnimationFrame(drawWaveform); // 下一帧重绘
}
});
ro.observe(containerRef.value); // 开始观察
// (注意:onUnmounted 时应 disconnect)
}
});

onUnmounted(() => {
if (animationFrame) cancelAnimationFrame(animationFrame);
if (audioContext) audioContext.close().catch(() => {});
// ResizeObserver 会在组件销毁时自动 GC,但显式 disconnect 更安全
});

为什么不用 window.onresize?因为 组件的容器可能不是因为窗口大小改变而变——比如父级 flex 布局、侧边栏展开/收起等。ResizeObserver 能精确感知元素自身的尺寸变化。

性能优化:requestAnimationFrame

1
2
3
4
5
// 每次 waveform 数据或 currentTime 变化时
watch([waveformData, () => props.currentTime], () => {
if (animationFrame) cancelAnimationFrame(animationFrame); // 取消上一帧
animationFrame = requestAnimationFrame(drawWaveform); // 下一帧再画
});

这确保了:

  • 不会在一秒内绘制 60 次(如果 currentTime 每帧都在变的话)
  • 总是绘制最新状态(RAF 在浏览器重绘前执行)
  • 不会卡顿(RAF 与浏览器的渲染节奏同步)

总结

步骤 API 输入 输出
获取数据 fetch / IPC readFileAsBase64 文件 URL/路径 ArrayBuffer
解码 AudioContext.decodeAudioData ArrayBuffer AudioBuffer (PCM)
降采样 手动循环 getChannelData Float32Array (132K+) 200 个 RMS 浮点数
归一化 Math.max + map 200 个浮点数 归一化到 [0,1]
绘制 Canvas 2D fillRect 200 个 [0,1] 数值 可视化柱状图
适配 devicePixelRatio + scale CSS 僐素值 Retina 清晰图像
交互 click event + emit 鼠标坐标 seek 时间戳
响应式 ResizeObserver + RAF 容器尺寸变化 自动重绘

零依赖、200 行代码、完整功能 —— 这就是 Canvas 原生 API 的威力。

下一篇深入 项目持久化与编码检测 —— “角色即文件夹”设计背后的工程考量。