MiMo TTS Studio 生成语音后,需要直观展示音频波形,让用户判断语速、停顿和音量分布。实现方案是 Web Audio API 解码 + Canvas 条形图绘制,纯前端能力,不需要 Node.js 参与。
音频解码:从 buffer 到波形数据
核心思路:将音频 buffer 解码为 PCM 数据,按固定间隔采样,归一化到 [0, 1]:
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
| const audioContext = ref(null) const isAnalyzing = ref(false)
async function analyzeAudio(src) { if (isAnalyzing.value) return isAnalyzing.value = true try { const arrayBuffer = await fetchAudioBuffer(src) if (!arrayBuffer) return if (!audioContext.value) { audioContext.value = new (window.AudioContext || window.webkitAudioContext)() } const audioBuffer = await audioContext.value.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('音频分析失败:', e) } finally { isAnalyzing.value = false } }
|
graph LR
A["AudioBuffer"] --> B["getChannelData(0)"]
B --> C["按 blockSize 分块"]
C --> D["每块取绝对值均值"]
D --> E["归一化到 0-1"]
E --> F["Canvas 绘制"]
关键参数:samples = 200 决定条形数量。太少则波形粗糙,太多则视觉拥挤。200 个条形在 400px 宽度的 Canvas 上,每条 2px,刚好合适。
Electron 环境下的音频获取
浏览器可以直接 fetch(url) 获取音频,但 Electron 的渲染进程没有直接文件系统访问。通过封装的 invoke 函数读取本地文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| async function fetchAudioBuffer(src) { try { if (src.startsWith('file://') || src.startsWith('/')) { const { invoke } = await import('@/utils/ipcRenderer') const { ipcApiRoute } = await import('@/api') const base64 = await invoke(ipcApiRoute.readFileAsBase64, { filePath: src }) if (!base64) return null const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes.buffer } const resp = await fetch(src) return resp.arrayBuffer() } catch (e) { console.warn('获取音频失败:', e) return null } }
|
这里 atob + charCodeAt 的转换是必要的——IPC 传输用 base64 编码,Web Audio API 需要 ArrayBuffer。
Canvas 条形图绘制
波形用垂直条形图表示,使用 getBoundingClientRect() + DPR 缩放适配 Retina 屏:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function drawWaveform() { const canvas = canvasRef.value if (!canvas || !waveformData.value?.length) return const data = waveformData.value const rect = canvas.getBoundingClientRect() const dpr = window.devicePixelRatio || 1 canvas.width = rect.width * dpr canvas.height = rect.height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) ctx.clearRect(0, 0, rect.width, rect.height)
const barWidth = rect.width / data.length data.forEach((value, i) => { const barH = Math.max(2, value * rect.height * 0.85) const x = i * barWidth const y = (rect.height - barH) / 2 const intensity = Math.floor(value * 255) ctx.fillStyle = `rgb(50, ${150 + intensity * 0.4}, ${100 + intensity * 0.6})` ctx.fillRect(x, y, barWidth - 1, barH) }) }
|
条形图居中绘制——y = (rect.height - barH) / 2 让波形从中心向上下对称扩展,视觉上类似音频编辑器的波形显示。
播放进度同步
播放时需要实时高亮当前播放位置。通过 requestAnimationFrame 同步播放时间和波形进度:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function updateProgress() { if (!audioElement || audioElement.paused) return const progress = audioElement.currentTime / audioElement.duration drawProgressLine(canvas, waveformData, progress) requestAnimationFrame(updateProgress) }
function drawProgressLine(canvas, data, progress) { drawWaveform() const ctx = canvas.getContext('2d') const rect = canvas.getBoundingClientRect() const x = progress * rect.width ctx.strokeStyle = '#ffffff' ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x, rect.height) ctx.stroke() }
|
每次 requestAnimationFrame 回调重绘一次。先重绘整个波形,再在当前位置画一条白色竖线。约 60fps 的刷新率,视觉上足够流畅。
Vue 组件封装
WaveformDisplay.vue 封装了完整的波形显示逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <template> <canvas ref="canvasRef" :width="width" :height="height" /> </template>
<script setup> const props = defineProps({ src: String, // 音频 URL 或本地路径 width: { type: Number, default: 400 }, height: { type: Number, default: 80 } })
const waveformData = ref([]) const isAnalyzing = ref(false)
watch(() => props.src, async (newSrc) => { if (newSrc) await analyzeAudio(newSrc) }, { immediate: true })
watch(waveformData, (data) => { if (data.length > 0 && canvasRef.value) { drawWaveform() } }) </script>
|
src 变化时自动重新分析,waveformData 变化时自动重绘。响应式驱动,不需要手动调用生命周期。
注意:以上代码均为简化示意,省略了部分状态管理和错误处理逻辑。
性能考虑
为什么不实时计算? decodeAudioData 对于长音频(几秒几十秒)可能需要 50200ms。如果每次播放都重新解码,会有明显卡顿。解码一次后缓存 waveformData,后续播放只更新进度条。
为什么用 Canvas 而不是 SVG? 200 个条形用 SVG 意味着 200 个 <rect> DOM 元素,更新时需要操作 DOM。Canvas 是位图绘制,clearRect + fillRect 的组合比 DOM 操作快一个数量级。
requestAnimationFrame 的节流效果:浏览器自动限制在 60fps,不会因为音频数据更新过快导致 CPU 空转。
前端可视化的核心不是”画出来”,是”高效地画出来”。解码缓存 + Canvas 位图 + rAF 节流,三者缺一不可。