前言

        微信小程序自己提供了录音相关的接口:wx.getRecorderManager(),想要获取录音时的音量大小,则主要是通过录音指定帧回调来计算。

        对于绘制录音的波形图,博主目前有两种方式:一种是使用微信自己的 Canvas 2d 进行绘图;另一种则是通过得到的波形图数组,直通过wx:for来遍历,每个波形数据对应一个元素标签view。不过从博主自己的实践中,感觉第二种方法好一些。

        首先Canvas 2d 绘制,在 Android 端从视觉上来看,基本上算是正常的,但在 IOS 端就有些问题了,如会每一次绘制都会闪烁之类的,也可能是博主自己不太会用吧。其次,便是Canvas的绘制宽度,是存在限制的,而如果想要更精细的获取音量大小数据,那么录音的指定帧大小就要更小,获取的分段和波形数据就会更多,导致宽度越来越长,Canvas无法承载更多的数据。

        关于为什么有将PCM格式转换为MP3,是因为录音时选择的是PCM格式,计算音量大小的方法,也只适用WAV与PCM格式,不适用与MP3格式,博主本人没有找到可以适用MP3的音量大小计算方法。同时,微信小程序的录音接口,指定帧回调,无法获取WAV格式的回调(文档是这么说的,不过博主实测在Android端,WAV格式可以获取回调,但IOS端不行)

基本参数

// 此处实际上并不是马上赋值的,只是将代码拆解出来,方便理解
// 录音接口
let recorderManager = null;
// 录音接口
recorderManager = wx.getRecorderManager()
// 录音启动
recorderManager.start(this.data.options);
// 文件管理接口
let fs = null
// 文件管理接口
fs = wx.getFileSystemManager()



// 录音参数 data内部的数据
options: {
  // 录音的时长 1min
  duration: 60 * 1000,
  // 采样率 22050 24000
  sampleRate: 12000,
  // 录音通道数
  numberOfChannels: 1,
  // 编码码率 39000 40000
  encodeBitRate: 64000,
  // 音频格式
  format: 'pcm',
  // 指定帧大小回调 KB
  frameSize: 0.8,
  // 指定录音的音频输入源
  audioSource: 'auto'
},
// 波形数据
waveData: [],
// 临时录音路径
tempRecordPath: '',
// 临时文件数据
tempRecordBuffer: null,

录音操作

在开始录音之前,需要先初始化一些操作,如录音监听的一系列回调:

监听录音开始:recorderManager.onStart()

        在得知录音开始后,便可以根据自己的需求,设计相关操作,如录音计时等。

监听已录制完指定帧大小的文件事件:recorderManager.onFrameRecorded()

        此回调为获取波形的一个关键步骤,通过监听已录制完指定帧大小的文件事件,获取得到的录音分片数据,需要注意的是,本人获取的回调中,前几段数据是空的(大概是前6条?具体情况具体分析叭,鸿蒙的部分机型前几段数据是正常的,Android与IOS测试的几台设备前6条回调都是空的),需要过滤一下。

        具体原因尚不明确,没有积极去了解,可能是录音没加载好,启动的前一小段时间还早准备中,导致数据无法计算?

        以及,原本是想获取WAV格式的录音回调,但发现在Android端可以拿到回调,但IOS却不行。官方文档目前也只明确说只能拿到PCM与MP3格式的回调。所以最后还是选择的PCM格式。

监听录音暂停:recorderManager.onPause()

        此方法在录音被暂停时被调用,可以用来处理一些操作,如计时暂停等。

监听录音结束事件:recorderManager.onStop()

        此回调则是监听录音结束,用来获取结束后的录音文件相关信息,如临时文件地址。通过这些信息,可以将文件转化为其他格式,以及保存文件等。

// 初始化录音相关监听
initRecord() {
  console.log('触发initRecord')
  // 监听录音开始事件
  recorderManager.onStart(() => {
    console.log('录音开始 recorder start')
    this.startCountdown()
  })
  // 监听已录制完指定帧大小的文件事件
  recorderManager.onFrameRecorded((res) => {
    const { frameBuffer } = res
    // console.log('录音指定帧 frameBuffer', frameBuffer, frameBuffer.byteLength)
    if (res.frameBuffer && res.frameBuffer.byteLength) {
      console.warn('指定帧回调,计算volume')
      // 自定义RMS计算
      const volume = calculateVolume(res.frameBuffer)
      // console.log('volume:', volume)
      // 初始会得到几个为0的回调数据,过滤掉
      if (volume) {
        // 将得到的波形数据保存
        this.data.waveData.push(volume.toFixed(2))
      }
      // 更新波形图数据
      this.setData({
        waveData: this.data.waveData
      })
      // console.log('自定义计算:', volume)
    }
  })
  // 监听录音暂停
  recorderManager.onPause(() => {
    console.warn('录音暂停')
  })
  // 监听录音结束事件
  recorderManager.onStop((res) => {
    console.log('录音结束 recorder stop', res)
    // 显示加载弹窗
    this.showLoading();
    new Promise((resolve, reject) => {
      try {
        // const result = convertWavToMp3(res.tempFilePath);
        // pcm 转 mp3 格式
        const result = convertPcmToMp3(res.tempFilePath, this.data.options.sampleRate, this.data.options.numberOfChannels);
        if (result) {
          resolve(result);
        } else {
          reject(new Error('音频转换失败,返回结果为空'));
        }
      } catch (error) {
        reject(error);
      }
    })
      // 处理得到的 mp3 数据
      .then(({ mp3FilePath, mp3Buffer }) => {
        console.log('拿到的mp3FilePath:', mp3FilePath);
        console.log('mp3Buffer:', mp3Buffer);

        // 设置录音临时路径
        this.setData({
          tempRecordPath: mp3FilePath,
          tempRecordBuffer: mp3Buffer.buffer,
          recordState: 'stopPlay',
        });
      })
      .catch(error => {
        console.error('音频处理失败:', error);
        wx.showToast({
          title: '音频处理失败',
          icon: 'none',
          duration: 1000,
        });
        // 关闭蒙版
        this.onClickHide()
        try {
          // 删除文件
          setTimeout(() => {
            console.log('准备删除文件')
            fs.unlinkSync(this.data.tempRecordPath)
            this.setData({
              tempRecordPath: ''
            })
          }, 500)
        } catch (e) {
          console.warn(e)
        }
      })
      .finally(() => {
        // 无论成功或失败都隐藏加载弹窗
        this.hideLoading();
      });
  })
},

计算音量大小

通过回调拿到的分段数据,计算此分段的录音大小calculateVolume()。

计算方法博主在网络上查找了许多内容,但不太适用,此计算方法为本人问AI的,测试了几次,符合我的预期,便直接使用了。

不过,部分机型,似乎不怎么会录入环境的声音,不说话的话,得到的回调就是0了,因此录音回调中的volume为0过滤的操作,还是需要优化一下的,可以将前几个0过滤掉(回调计算结果为0),随后,如果后续通过calculateVolume计算得到的音量大小是0的话,可以将手动将音量大小设置为1这样的(也可以默认将最小值设置为1这样的,就不会出现空数据了)。

// 计算录音数据对应的音量大小
function calculateVolume(pcmData) {
  // 将ArrayBuffer转换为16位有符号整数数组
  const int16Array = new Int16Array(pcmData);
  // console.log('转换后的int16Array:', int16Array)

  // 计算RMS (Root Mean Square)
  let sum = 0;
  for (let i = 0; i < int16Array.length; i++) {
    const sample = int16Array[i] / 32768.0; // 归一化到[-1, 1]
    sum += sample * sample;
  }
  // console.log('计算后的sum:', sum)

  const rms = Math.sqrt(sum / int16Array.length);
  // console.log('初始的rms:', rms)

  // 转换为音量大小 (避免log10(0)的情况)
  const db = 20 * Math.log10(Math.max(rms, 0.0001));
  // console.log('转换为音量大小', db)

  // 转换为0-100的线性值 (假设-60dB是静音,0dB是最大值)
  // let volume = Math.max(0, (db + 60) / 60 * 100);
  // 因得到为0的情况非常少见,因此这里将最小值0改为1 具体怎么改看个人情况
  let volume = Math.max(1, (db + 60) / 60 * 100);
  // console.log('转换为0-100的线性值', volume)
  volume = Math.min(100, volume); // 限制在0-100

  return volume;
}

最后返回的 volume则是当前回调对应的音量大小,将其保存至waveData数组内,用来绘制波形图。只要拿到了数据,怎么使用便是随便了,使用Canvas也好,元素标签遍历也好,看各自的喜好就行。

PCM转MP3

PCM数据转MP3的方法,需要借助外部库实现,这里是用的是lamejs

        lamejs - npm

这里博主是直接下载的lame.all.js文件,保存在本地直接使用的

需要注意的是,如果是本地引用的话,需要在文件末尾加上以下代码将lamejs暴露出来:

代码:

import lamejs from '/utils/lame/lame.all'
const lame = lamejs
// 录音转MP3的核心方法
function convertPcmToMp3(pcmFilePath, sampleRate = 12000, channels = 1, bitRate = 32) {
  try {
    // 1. 读取PCM文件内容
    const pcmBuffer = fs.readFileSync(pcmFilePath);
    // console.log('读取PCM文件内容', pcmBuffer)

    const pcmData = pcmBuffer
    // console.log('提取PCM数据', pcmData)

    // 1. 确保PCM数据是Int16Array格式
    let samples;
    if (pcmData instanceof ArrayBuffer) {
      samples = new Int16Array(pcmData);
    } else if (ArrayBuffer.isView(pcmData)) {
      samples = new Int16Array(pcmData.buffer);
    } else {
      throw new Error('不支持的PCM数据格式');
    }
    // console.log('转换后的Int16Array:', samples);

    // 2. 创建LAME编码器实例
    const mp3Encoder = new lame.Mp3Encoder(
      channels,     // 声道数 (直接传入)
      sampleRate,   // 采样率 (直接传入)
      bitRate       // 比特率(kbps)
    );
    // console.log('编码器配置:', { channels, sampleRate, bitRate });

    // 3. 编码为MP3
    const mp3Buffer = mp3Encoder.encodeBuffer(samples);
    // console.log('编码后的MP3数据:', mp3Buffer);

    // 4. 生成MP3文件路径
    const mp3FilePath = `${wx.env.USER_DATA_PATH}/Record_${Date.now()}.mp3`;
    console.log('生成的MP3文件路径:', mp3FilePath);

    // 5. 将MP3数据写入文件
    fs.writeFileSync(mp3FilePath, mp3Buffer.buffer, 'binary');
    const fileStats = fs.statSync(mp3FilePath);
    
    console.log(`文件大小:${fileStats.size} 字节`);
    console.log(`文件大小:${(fileStats.size / 1024).toFixed(2)} KB`);
    
    return { 
      mp3FilePath, 
      mp3Buffer: new Uint8Array(mp3Buffer) // 返回Uint8Array格式
    };

  } catch (error) {
    console.error('PCM转MP3失败:', error);
    return null;
  }
}

附带WAV格式转MP3

实际上WAV比PCM只是多了44字节的头部信息,其实转化操作基本上是一样的

// 录音转MP3的核心方法
function convertWavToMp3(wavFilePath) {
  try {
    // 1. 读取WAV文件内容
    const wavBuffer = fs.readFileSync(wavFilePath);
    console.log('读取WAV文件内容', wavBuffer)

    // 2. 解析WAV文件头(获取采样率、声道数等信息)
    const wavHeader = parseWavHeader(wavBuffer);
    console.log('解析WAV文件头(获取采样率、声道数等信息)', wavHeader)

    // 3. 提取PCM数据(WAV文件头之后的数据部分)
    const pcmData = wavBuffer.slice(44); // WAV文件头通常为44字节
    console.log('提取PCM数据(WAV文件头之后的数据部分)', pcmData)

    // 4. 创建LAME编码器实例
    const mp3Encoder = new lame.Mp3Encoder(
      wavHeader.channels,     // 声道数
      wavHeader.sampleRate, // 采样率
      32 // 比特率(kbps)默认 128
    );

    // 5. 将PCM数据转换为Int16Array(LAME需要的格式)
    const samples = new Int16Array(pcmData);

    // 6. 编码为MP3
    const mp3Buffer = mp3Encoder.encodeBuffer(samples);
    console.log('编码为MP3', mp3Buffer)

    // 8. 生成MP3文件路径
    const mp3FilePath = `${wx.env.USER_DATA_PATH}/recording_${Date.now()}.mp3`;
    console.log('生成MP3文件路径', mp3FilePath)

    // 9. 将MP3数据写入文件
    fs.writeFileSync(mp3FilePath, mp3Buffer.buffer, 'binary')
    const aaa = fs.readFileSync(mp3FilePath)
    const bbb = fs.statSync(mp3FilePath)

    // console.log('MP3转换成功:', mp3FilePath, aaa, bbb);
    console.log(`文件大小:${bbb.size} 字节`);
    console.log(`文件大小:${(bbb.size / 1024).toFixed(2)} KB`);
    console.log(`文件大小:${(bbb.size / 1024 / 1024).toFixed(2)} MB`);
    return { mp3FilePath, mp3Buffer };

  } catch (error) {
    console.error('MP3转换失败:', error);
    return null;
  }
}

// 解析WAV文件头
function parseWavHeader(buffer) {
  const view = new DataView(buffer);

  // 确保是WAV文件
  const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
  const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));
  if (riff !== 'RIFF' || wave !== 'WAVE') {
    throw new Error('不是有效的WAV文件');
  }

  // 提取关键信息
  const sampleRate = view.getUint32(24, true);
  const channels = view.getUint16(22, true);
  const bitDepth = view.getUint16(34, true);

  return {
    sampleRate,
    channels,
    bitDepth,
    dataOffset: view.getUint32(40, true) // 数据起始位置
  };
}

波形图绘制

博主Canvas 2d的实现方式其实是比较复杂的,备份的代码不太完全,整理起来也有些麻烦,因此就偷懒不做展示了,这边只展示通过wx:for遍历元素的方法:

<scroll-view
  class="wave-container"
  scroll-x="true"
  scroll-left="{{scrollLeft}}"
  enhanced
  bounces="{{true}}"
  show-scrollbar="{{false}}"
  enable-flex
>
  <view
    class="wave-wrapper"
    style="width: {{waveData.length * 5}}px; margin-left: {{marginLeft}}px;"
  >
    <view
    class="wave-item"
    wx:for="{{waveData}}"
    wx:key="item"
    style="height: {{item / 100 * 75}}px;"
    ></view>
  </view>
</scroll-view>
Component({
  properties: {
    waveData: {
      type: Array,
      value: [],
      observer(newVal) {
        // console.log('得到的新波形数据:', newVal.at(-1), newVal)
        if (newVal && newVal.length > 0) {
          let marginLeft = 0;
          const waveWidth = newVal.length * 5
          if (waveWidth < this.data.containerWidth) {
            marginLeft = this.data.containerWidth - waveWidth;
          }
          this.setData({
            marginLeft: marginLeft,
            scrollLeft: waveWidth - this.data.containerWidth
          })
        }
      }
    },
  },

  data: {
    containerWidth: 0,
    containerHeight: 0,
    scrollLeft: 0,
    marginLeft: 0,
  },

  lifetimes: {
    attached() {
      this.getContainerRect()
    },
    detached() {
    }
  },

  methods: {
    // 获取容器尺寸
    getContainerRect() {
      return new Promise((resolve) => {
        wx.createSelectorQuery()
          .in(this)
          .select('.wave-container')
          .boundingClientRect(rect => {
            console.log('获取容器尺寸')
            if (rect) {
              this.setData({
                containerWidth: rect.width,
                containerHeight: rect.height
              }, resolve);
              console.log('尺寸:', this.data.containerWidth, this.data.containerHeight)
            } else {
              // 添加重试机制
              setTimeout(() => {
                console.log('重试获取尺寸')
                this.getContainerRect().then(resolve);
              }, 100);
            }
          })
          .exec();
      });
    },
  }
});
.wave-container {
  width: 100%;
  height: 75px;
  overflow-x: auto;
  background-color: #654321;
  position: relative;
}

.wave-wrapper {
  height: 75px;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: flex-end;
}

.wave-item {
  width: 1px;
  height: 0px;
  margin: 2px;
  border-radius: 5px;
  background-color: #123456;
}
/*偷偷改个颜色awa*/

总结

        从实践来看,微信小程序录音方面的一些接口,Android端与IOS端的一些效果是不太一样的,比如录音回调和错误回调等,以及两端录音所需要设置的参数、音频播放的相关效果也有些不太相同,存在兼容性问题。录音波形这一块的后续操作也是折煞我也,太菜了是这样的QwQ。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐