用 PCM 实时显示录音振幅波形

在 UniApp 里用 PCM 录音帧,实时计算 RMS,画出稳定的振幅波形

前一篇里说过我在做一个“朗读打卡”的小程序。 因为不是商业化产品,所以我不急着兼容所有端,只要能在小程序里稳定跑起来就行。

这篇记一件小事:录音过程中怎么实时显示振幅波形。 我尽量不写成教材,只留最关键的思路和代码,给自己备忘,也希望能帮到刚好在做类似事情的人。


一、先把录音跑起来

在 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 实时画出波形。

对我来说,这类功能没多复杂,但把体验做好要很细: 拿到数据只是第一步,平滑、增益、门限、响应速度,都是“多一点就不好看、少一点就不真实”。

等后面把云端转码和存储也接好了,再继续写下一篇。