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
// WaveformDisplay.vue — audioContext 创建一次并复用(单例)
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
}
}

关键参数: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
// WaveformDisplay.vue — 使用封装的 invoke 函数
async function fetchAudioBuffer(src) {
try {
if (src.startsWith('file://') || src.startsWith('/')) {
// 本地文件:通过 IPC 读取
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
// WaveformDisplay.vue
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 节流,三者缺一不可。