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 async function fetchAudioBuffer (src ) { if (!src) return null ; if (src.startsWith ('file://' ) || src.startsWith ('/' )) { if (window .require ) { 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) { 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 ; } } } } 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 的渲染进程里)。所以需要绕道:
前端把路径发给主进程 IPC
主进程用 fs.readFileSync 读文件
转 Base64 返回给前端
前端解码回二进制
这比看起来要高效——因为 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 { const arrayBuffer = await fetchAudioBuffer (src); if (!arrayBuffer) return ; if (!audioContext) { audioContext = new (window .AudioContext || window .webkitAudioContext )(); } const audioBuffer = await audioContext.decodeAudioData (arrayBuffer); const channelData = audioBuffer.getChannelData (0 ); const samples = 200 ; const blockSize = Math .floor (channelData.length / samples); const data = []; for (let i = 0 ; i < samples; i++) { let sum = 0 ; for (let j = 0 ; j < blockSize; j++) { sum += Math .abs (channelData[i * blockSize + j]); } data.push (sum / blockSize); } const max = Math .max (...data, 0.01 ); 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 (); const dpr = window .devicePixelRatio || 1 ; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale (dpr, dpr); const width = rect.width ; const height = rect.height ; 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); const ratio = i / data.length ; 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);
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 (() => { if (animationFrame) cancelAnimationFrame (animationFrame); if (audioContext) audioContext.close ().catch (() => {}); });
为什么不用 window.onresize?因为 组件的容器可能不是因为窗口大小改变而变 ——比如父级 flex 布局、侧边栏展开/收起等。ResizeObserver 能精确感知元素自身的尺寸变化。
性能优化:requestAnimationFrame 1 2 3 4 5 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 的威力。
下一篇深入 项目持久化与编码检测 —— “角色即文件夹”设计背后的工程考量。