鸿蒙PCM录音功能开发

581 阅读3分钟

鸿蒙PCM录音功能开发

前言

和之前写的安卓PCM音频录制一样的需求,这里需要用鸿蒙next来实现,代码写的比较早,有些API被标记废弃了,但是不影响使用,这里简单抽时间记录下。

录音

录音配置

通过PCM录音,需要配置采样率、通道、采样格式、编码格式等:

  /** 采样配置 **/
  private audioStreamInfo: audio.AudioStreamInfo = {
    samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 采样率
    channels: audio.AudioChannel.CHANNEL_1, // 通道
    sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 采样格式
    encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // 编码格式
  }

  /** 声源配置 **/
  private audioCapturerInfo: audio.AudioCapturerInfo = {
    source: audio.SourceType.SOURCE_TYPE_MIC,
    capturerFlags: 0
  };

  /** 录音配置 **/
  private audioCapturerOptions: audio.AudioCapturerOptions = {
    streamInfo: this.audioStreamInfo,
    capturerInfo: this.audioCapturerInfo
  };

写好配置后,就可以获取录音器了:

  // 录音
  private audioCapturer: audio.AudioCapturer = null!
  private isRecording: Boolean = false
  
  /** 录音初始化 **/
  private isInitRecord: Boolean = false
  private async initRecord(): Promise<void>  {
    return new Promise((resolve, reject) => {
      audio.createAudioCapturer(this.audioCapturerOptions, (err, data) => {
        // 发生异常
        if (err) {
          LogUtil.e("createAudioCapturer fail")
          reject()
        }else {
          // 拿到录音器
          this.audioCapturer = data;
          this.isInitRecord = true;
          LogUtil.e("createAudioCapturer success!")
          resolve()
        }
      });
    });
  }

这里代码写的不咋样,凑合看下呗。

开始录音

拿到录音器AudioCapturer后就可以录音了,记得提前申请下录音权限,前面写了工具类了,这里就不说了:

  /**
   * 开始录音
   *
   * @param context 上下文
   * @param key 关键key
   * @param resolve 成功回调
   * @param reject 失败回调
   */
  async startPcmRecord(context: Context, key: string, resolve: Function, reject: Function){
    try {
      // 等待初始化
      if (!this.isInitRecord) {
        await this.initRecord()
      }

      // 当且仅当状态为STATE_PREPARED、STATE_PAUSED和STATE_STOPPED之一时才能启动采集
      let stateGroup = [
        audio.AudioState.STATE_PREPARED,
        audio.AudioState.STATE_PAUSED,
        audio.AudioState.STATE_STOPPED
      ];
      if (stateGroup.indexOf(this.audioCapturer.state) === -1) {
        LogUtil.d("start record failed: state error")
        reject("start record failed: state error")
        return;
      }

      // 启动采集
      await this.audioCapturer.start()

      // 创建音频缓存目录
      let dir = context.cacheDir + '/audio'
      if (!fs.accessSync(dir)) {
        fs.mkdirSync(dir)
      }
      let path = context.cacheDir + `/audio/${key}.wav`;
      // 删除原来的文件
      if (fs.accessSync(path)) {
        fs.unlinkSync(dir)
      }
      let file: fs.File = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      let fd = file.fd;

      // option类
      class Options {
        offset?: number;
        length?: number;
      }

      // 读取数据
      let count = 0;
      this.isRecording = true;
      while (this.isRecording) {
        let bufferSize = await this.audioCapturer.getBufferSize();
        bufferSize = Math.max(this.PCM_PACKET_SIZE, bufferSize);
        let buffer = await this.audioCapturer.read(bufferSize, true);
        let options: Options = {
          offset: count * bufferSize,
          length: bufferSize
        };
        if (buffer === undefined) {
          // reject("read buffer error!")
        } else {

          // 向文件写入
          fs.writeSync(fd, buffer, options);
        }
        count++;
      }
    } catch (e) {
      LogUtil.e("startPcmRecord: " + (e as BusinessError).message)
      reject(e)
    }
  }

代码稍微有点长,不过逻辑按顺序下来还是比较简单的,最后pcm录音数据保存再我们创建的临时文件内。

这里因为要借讯飞的语音识别,我还得把数据切成一段一段,再每隔一段时间发出去,其实也好写,我这先定义了一个队列来保存,每次将数据通过enqueueData保存,就能做成分段,取的时候从队列取就可以了:

  /** 数据包大小 **/
  private PCM_PACKET_SIZE: number = 1280

  /** 数据传输间隔 **/
  private PCM_SEND_DELAY = 40

  /** 数据队列,保存1280字节一个的packet **/
  private mDataQueue: LinkedList<Uint8Array> = new LinkedList();

  /** 上次未满 **/
  private mRemainingData: Uint8Array = new Uint8Array();

  // 将数据保存未 1280 字节的packet放到队列中
  private enqueueData(data: Uint8Array): void {
    let combinedData = new Uint8Array(this.mRemainingData.length + data.length);
    combinedData.set(this.mRemainingData);
    combinedData.set(data, this.mRemainingData.length);

    let offset = 0;

    while (offset < combinedData.length) {
      const packetSize = Math.min(this.PCM_PACKET_SIZE, combinedData.length - offset);
      const packet = new Uint8Array(packetSize);
      packet.set(combinedData.subarray(offset, offset + packetSize));
      this.mDataQueue.add(packet);
      offset += packetSize;
    }

    this.mRemainingData = combinedData.subarray(offset);
  }

数据来源也简单,在上面写入文件的地方,同事保存一份到队列:

    if (buffer === undefined) {
      // reject("read buffer error!")
    } else {

      // 向文件写入
      fs.writeSync(fd, buffer, options);

      // 传入队列保存
      this.enqueueData(new Uint8Array(buffer))
    }

启动定时也好做,在启动录音的时候加个定时器就可以了:

  // 启动采集
  await this.audioCapturer.start()
  this.mDataQueue.clear();
  // 启动定时回调
  let base64 = new util.Base64Helper();
  let timer = setInterval(()=>{

    // 从队列取数据
    if (this.mDataQueue.length > 0) {
      let packet = this.mDataQueue.removeFirst();
      resolve(base64.encodeToStringSync(packet));
      LogUtil.d("setInterval get packet:" + JSON.stringify(packet));

    }else if (!this.isRecording) {
      // 队列为空且结束录音了,结束packet传输
      LogUtil.d("setInterval finish");
      resolve("end");
      this.mDataQueue.clear();
      clearInterval(timer)
    }
  }, this.PCM_SEND_DELAY);

这里加了个base64转换传输出去,看需要吧,最后通过resolve回调的。

结束录音

录音的代码比较多,结束录音就简单了,没什么好说的,甚至可以不管异常一行代码解决:

  /**
   * 关闭录音
   */
  stopPcmRecord(){
    // 只有采集器状态为STATE_RUNNING或STATE_PAUSED的时候才可以停止
    if (this.audioCapturer.state.valueOf() !== audio.AudioState.STATE_RUNNING &&
      this.audioCapturer.state.valueOf() !== audio.AudioState.STATE_PAUSED) {
      LogUtil.d("stop record fail: state not right")
      return;
    }

    this.audioCapturer.stop((err: BusinessError) => {
      if (err) {
        LogUtil.d("stop record fail: " + err.message)
      } else {
        this.isRecording = false
      }
    });
  }

播放

音频播放配置

要播放PCM音频也简单,和录音差不多,先配置下:

  /** 播放音频配置 **/
  private audioRendererInfo: audio.AudioRendererInfo = {
    usage: audio.StreamUsage.STREAM_USAGE_MUSIC, // 音频流使用类型
    rendererFlags: 0 // 音频渲染器标志
  }

  /** 播放配置 **/
  private audioRendererOptions: audio.AudioRendererOptions = {
    streamInfo: this.audioStreamInfo,
    rendererInfo: this.audioRendererInfo
  }

  // 播放器
  private audioRenderer: audio.AudioRenderer = null!

  /** 播放器初始化 **/
  private isInitAudio: Boolean = false
  private async initAudio(): Promise<void>  {
    return new Promise((resolve, reject) => {
      audio.createAudioRenderer(this.audioRendererOptions, (err, renderer) => {
        // 发生异常
        if (err) {
          reject()
        }else {
          // 拿到录音器
          this.audioRenderer = renderer;
          this.isInitAudio = true;
          resolve()
        }
      });
    });
  }

播放PCM音频

拿到录音器audioRenderer后,就可以播放音频了:

  private currentPlayingCount = 0;

  /**
   * 播放PCM音频
   */
  async playPcmRecord(context: Context, key: string, resolve: Function, reject: Function) {
    try {
      // 创建音频缓存路径,文件不存在直接返回
      let path = context.cacheDir + `/audio/${key}.wav`;
      if (!fs.accessSync(path)) {
        reject("file not exit")
        return
      }

      if (!this.isInitAudio) {
        await this.initAudio()
      }

      // 只有渲染器状态为running或paused的时候才可以停止
      if (this.audioRenderer.state == audio.AudioState.STATE_RUNNING ||
        this.audioRenderer.state == audio.AudioState.STATE_PAUSED) {
        await this.audioRenderer.stop();
      }

      // 自旋等待
      this.currentPlayingCount++;
      while (this.currentPlayingCount > 1) {

      }

      // 当且仅当状态为prepared、paused和stopped之一时才能启动渲染
      let stateGroup = [
        audio.AudioState.STATE_PREPARED,
        audio.AudioState.STATE_PAUSED,
        audio.AudioState.STATE_STOPPED
      ];
      if (stateGroup.indexOf(this.audioRenderer.state.valueOf()) === -1) {
        LogUtil.e("play pcm failed: state error")
        reject("play pcm failed: state error")
        return;
      }

      // 启动渲染
      await this.audioRenderer.start();

      // option类
      class Options {
        offset?: number;
        length?: number;
      }

      // 开始向音频播放器写入数据
      let bufferSize = await this.audioRenderer.getBufferSize();
      let file = fs.openSync(path, fs.OpenMode.READ_ONLY);
      let stat = await fs.stat(path);
      let buf = new ArrayBuffer(bufferSize);

      // 读取次数
      let len = stat.size % bufferSize === 0 ? Math.floor(stat.size / bufferSize) : Math.floor(stat.size / bufferSize + 1);
      for (let i = 0; i < len && this.currentPlayingCount <= 1; i++) {

        // 按缓存长度读取
        let options: Options = {
          offset: i * bufferSize,
          length: bufferSize
        };

        // 读取文件
        await fs.read(file.fd, buf, options);

        // buf是要写入缓冲区的音频数据,在调用AudioRenderer.write()方法前可以进行音频数据的预处理,实现个性化的音频播放功能,
        // AudioRenderer会读出写入缓冲区的音频数据进行渲染
        await this.audioRenderer.write(buf);

        // 如果渲染器状态为released,停止渲染
        if (this.audioRenderer.state === audio.AudioState.STATE_RELEASED) {
          fs.close(file);
          await this.audioRenderer.stop();
        }

        // 如果音频文件已经被读取完,停止渲染
        if(this.audioRenderer.state === audio.AudioState.STATE_RUNNING) {
          if (i === len - 1) {
            fs.close(file);
            await this.audioRenderer.stop();
          }
        }
      }
      this.currentPlayingCount--;
      resolve("");
    }catch (e) {
      LogUtil.e("" + e);
      reject(e);
    }
  }

和录制音频差不多,不过这里我修复了一个问题,就是如果同时播放的话音频会重叠,虽然已经调用了stop但是还是会有问题,用了个计数解决。

(代码比较赶,很多是cv的,能用就不想改了,凑合用吧)

停止播放音频

停止播放的代码也非常简单:

 /**
   * 停止播放PCM音频
   */
  stopPlayPcmRecord(){
    // 只有渲染器状态为running或paused的时候才可以停止
    if (this.audioRenderer.state !== audio.AudioState.STATE_RUNNING &&
      this.audioRenderer.state !== audio.AudioState.STATE_PAUSED) {
      LogUtil.e("stop pcm failed: state error")
      return;
    }

    // 停止渲染
    this.audioRenderer.stop();
  }

释放资源

很多时候写代码都会忘记释放资源,这里记得在不用的时候调用下:

  /**
   * 释放资源
   */
  release() {
    // 采集器状态不是STATE_RELEASED或STATE_NEW状态,才能release
    if (this.audioCapturer && this.audioCapturer.state.valueOf() != audio.AudioState.STATE_RELEASED &&
      this.audioCapturer.state.valueOf() != audio.AudioState.STATE_NEW) {
      this.audioCapturer.release();
    }

    // 渲染器状态不是released状态,才能release
    if (this.audioRenderer && this.audioRenderer.state != audio.AudioState.STATE_RELEASED) {
      this.audioRenderer.release();
    }
  }

小结

简单用鸿蒙的API写了下PCM录音功能,保存到临时文件,并且支持播放音频,记录下。