前一篇里说过我在做一个“朗读打卡”的小程序。 因为不是商业化产品,所以我不急着兼容所有端,只要能在小程序里稳定跑起来就行。
这篇记一件小事:录音过程中怎么实时显示振幅波形。 我尽量不写成教材,只留最关键的思路和代码,给自己备忘,也希望能帮到刚好在做类似事情的人。
一、先把录音跑起来
在 UniApp 里录音很直观,核心入口是 uni.getRecorderManager()。
我做的事情不复杂:初始化、监听开始/结束/错误事件,必要时把录到的 PCM 保存成 WAV。
初始化(页面生命周期里做):
let recorderManager = null;
onMounted(() => {
recorderManager = uni.getRecorderManager();
recorderManager.onStart(() => {
console.log('Recorder started');
});
recorderManager.onStop((res) => {
console.log('Recorder stopped:', res);
// 停止后把波形清空一下
waveAmplitudes.value = new Array(30).fill(12);
rmsPeak = 0;
lastAmp = 10;
// 保存成 WAV
if (res.tempFilePath) {
savePcmAsWav(res.tempFilePath)
.then((wavPath) => {
console.log('WAV saved:', wavPath);
})
.catch((err) => {
console.error('Save WAV failed:', err);
uni.showToast({ title: '保存失败', icon: 'none' });
});
} else {
uni.showToast({ title: '保存失败', icon: 'none' });
}
});
recorderManager.onError((err) => {
console.error('Recorder error:', err);
uni.showToast({ title: '录音失败,请重试', icon: 'none' });
});
});
开始录制时,我用的是 PCM:
const handleRecordClick = () => {
uni.setNavigationBarTitle({ title: '正在录音' });
if (recorderManager) {
recorderManager.start({
duration: 600000, // 10 minutes
sampleRate: 16000,
numberOfChannels: 1,
format: 'PCM',
frameSize: 1
});
}
};
这里选 PCM 的原因也很简单:
- PCM 是未编码的原始数据,直接就能算振幅
- 录成 WAV 只需要补头信息
- 如果要 MP3,实时解码开销大,体验不稳定
无损格式体积确实大,但我会交给服务端做转码,先把“实时反馈”这个体验搞稳。
二、波形怎么画
结构很简单,就是一排竖条,实时改高度:
<view class="waveform-active">
<view
class="wave-active"
v-for="(amplitude, i) in waveAmplitudes"
:key="i"
:style="{ height: `${amplitude}px` }"
></view>
</view>
样式也很朴素:
.waveform-active {
display: flex;
align-items: center;
justify-content: center;
height: 140px;
.wave-active {
width: 3px;
height: 12px;
background-color: #FF8FAB;
margin: 0 2px;
border-radius: 2px;
transition: height 0.1s ease-out;
}
}
视觉效果不需要太复杂,重点是“响应要稳”。
三、PCM → RMS → 波形
核心是 onFrameRecorded。
录音时每一帧 PCM 都会回调到这里,取出采样数据,算 RMS,再把它映射成波形高度。
const MIN_WAVE_AMP = 5;
const waveAmplitudes = ref(new Array(30).fill(MIN_WAVE_AMP));
recorderManager.onFrameRecorded((res) => {
if (res.isLastFrame) return;
const { frameBuffer } = res;
if (frameBuffer.byteLength > 0) {
const rms = getRMS(frameBuffer);
// 固定增益 + 噪声门限
const gain = 3.5;
const gated = rms < 0.02 ? 0 : rms;
const normalized = Math.min(1, gated * gain);
// 非线性放大小信号,再映射到可视区间
const target = Math.max(
MIN_WAVE_AMP,
Math.min(140, Math.pow(normalized, 0.6) * 140)
);
// 攻击快、释放慢,让视觉更“稳”
const alpha = target > lastAmp ? 0.8 : 0.35;
lastAmp = lastAmp + (target - lastAmp) * alpha;
const newAmplitudes = [...waveAmplitudes.value];
newAmplitudes.shift();
newAmplitudes.push(Math.round(lastAmp));
waveAmplitudes.value = newAmplitudes;
}
});
function getRMS(frameBuffer) {
const byteLength = frameBuffer.byteLength - (frameBuffer.byteLength % 2);
if (byteLength <= 0) return 0;
const view = new DataView(frameBuffer, 0, byteLength);
let sumSquares = 0;
const sampleCount = byteLength / 2;
for (let i = 0; i < sampleCount; i++) {
const sample = view.getInt16(i * 2, true) / 32768;
sumSquares += sample * sample;
}
return Math.sqrt(sumSquares / sampleCount);
}
这段逻辑我反复调过几次,最终的关键是两点:
- 噪声门限,不然静默时也会抖
- 攻击快、释放慢,波形才“像真的”
效果如下:

小结
这篇只是一个小切片: 怎么在小程序里用 PCM 实时画出波形。
对我来说,这类功能没多复杂,但把体验做好要很细: 拿到数据只是第一步,平滑、增益、门限、响应速度,都是“多一点就不好看、少一点就不真实”。
等后面把云端转码和存储也接好了,再继续写下一篇。