HarmonyOS《柚兔学伴》项目实战16-录音功能与权限管理

第16篇:录音功能与权限管理

引言

柚兔学伴的口语对话功能允许用户通过语音与 AI 交流,而非手动打字。这需要完整的录音流程:请求麦克风权限 → 创建录音器 → 录音 → 停止 → 上传 → 语音识别。本篇将深入剖析 HarmonyOS 的 AVRecorder 录音 API、运行时权限管理机制,以及录音 UI 的交互设计。

RecordUtils 单例

录音工具类采用单例模式,封装了 AVRecorder 的完整生命周期:

typescript 复制代码
// common/src/main/ets/util/RecordUtils.ets
export class RecordUtils {
  private filesDir = getContext(this).filesDir;
  private fdPath: string | undefined = undefined
  private filePath: string | undefined = undefined
  private file: fs.File | undefined = undefined

  private static mInstance: RecordUtils;

  private constructor() {
  }

  static getInstance(): RecordUtils {
    if (!RecordUtils.mInstance) {
      RecordUtils.mInstance = new RecordUtils();
    }
    return RecordUtils.mInstance;
  }

  private avRecorder: media.AVRecorder | undefined = undefined;
}

单例的必要性:AVRecorder 是有状态的资源,全局只应存在一个录音实例,避免多个录音器同时占用麦克风导致冲突。

录音配置参数

typescript 复制代码
private avProfile: media.AVRecorderProfile = {
  audioBitrate: 100000,                              // 音频比特率 100kbps
  audioChannels: 2,                                   // 双声道
  audioCodec: media.CodecMimeType.AUDIO_AAC,          // AAC 编码
  audioSampleRate: 48000,                             // 48kHz 采样率
  fileFormat: media.ContainerFormatType.CFT_MPEG_4A,  // M4A 封装格式
};

private avConfig: media.AVRecorderConfig = {
  audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,  // 麦克风输入
  profile: this.avProfile,
  url: 'fd://',  // 占位,运行时替换为实际 fd
};

参数选择说明:

  • AAC 编码:HarmonyOS 当前只支持 AAC 音频编码格式
  • M4A 封装:对应 AAC 编码,HarmonyOS 当前只支持 M4A 封装格式
  • 48kHz 采样率:CD 音质标准,满足语音识别精度需求
  • 双声道:保证音频质量,上传后语音识别更准确
  • fd:// 协议:HarmonyOS 文件访问协议,通过文件描述符传递录音数据

文件创建与 fd 获取

typescript 复制代码
getFiledFd(): void {
  this.filePath = this.filesDir + `/audio_${Date.now()}.m4a`
  this.file = fs.openSync(this.filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
  console.info(`录音文件地址:${this.file.fd}`)
  this.fdPath = 'fd://' + this.file.fd;
  this.avConfig.url = this.fdPath;
}

关键步骤:

  1. 以时间戳命名文件,避免冲突:audio_1700000000000.m4a
  2. 使用 fs.openSync 以读写+创建模式打开文件
  3. 将文件描述符 fd 转换为 fd:// 协议路径
  4. fdPath 赋给 avConfig.url,AVRecorder 将录音数据写入此文件

录音回调注册

typescript 复制代码
setAudioRecorderCallback() {
  if (this.avRecorder != undefined) {
    this.avRecorder.on('stateChange', (state: media.AVRecorderState, reason: media.StateChangeReason) => {
      console.info(`AudioRecorder current state is ${state}`);
    })
    this.avRecorder.on('error', (err: BusinessError) => {
      console.error(`AudioRecorder failed, code is ${err.code}, message is ${err.message}`);
    })
  }
}
  • stateChange:监听录音器状态变化(created → prepared → started → stopped → released),便于调试和状态追踪
  • error:录音错误回调,如麦克风被占用、存储空间不足等异常

完整录音流程

开始录音

typescript 复制代码
async startRecordingProcess() {
  if (this.avRecorder != undefined) {
    await this.avRecorder.release();
    this.avRecorder = undefined;
  }
  try {
    // 1. 创建录制实例
    this.avRecorder = await media.createAVRecorder();
    // 2. 注册回调
    this.setAudioRecorderCallback();
    // 3. 获取文件 fd
    this.getFiledFd()
    // 4. 配置录制参数完成准备工作
    await this.avRecorder.prepare(this.avConfig);
    // 5. 开始录制
    await this.avRecorder.start();
  } catch (e) {
    this.avRecorder!!.state
    e
  }
}

状态流转:createAVRecorderpreparestart,对应 AVRecorder 的 idle → prepared → started 状态变迁。开始前若已有旧实例,先 release 释放。

停止录音

typescript 复制代码
async stopRecordingProcess(): Promise<string> {
  if (this.avRecorder != undefined) {
    // 1. 停止录制
    if (this.avRecorder.state === 'started' || this.avRecorder.state === 'paused') {
      await this.avRecorder.stop();
    }
    // 2. 释放录制实例
    await this.avRecorder.release();
    this.avRecorder = undefined;
    // 3. 关闭录制文件 fd
    if (this.fdPath) {
      fs.closeSync(this.file)
      this.fdPath = undefined;
    }
  }
  return this.filePath!!
}

停止流程严格遵循:stopreleasecloseSync。释放 AVRecorder 后必须关闭文件描述符,否则文件可能写入不完整。方法返回录音文件的完整路径,供后续上传使用。

暂停与恢复

typescript 复制代码
async pauseRecordingProcess() {
  if (this.avRecorder != undefined && this.avRecorder.state === 'started') {
    await this.avRecorder.pause();
  }
}

async resumeRecordingProcess() {
  if (this.avRecorder != undefined && this.avRecorder.state === 'paused') {
    await this.avRecorder.resume();
  }
}

暂停和恢复都检查当前状态,确保只在合法状态下调用。pause 只能在 started 状态调用,resume 只能在 paused 状态调用。

运行时权限管理

HarmonyOS 对麦克风等敏感权限采用运行时授权机制,用户必须在使用时主动同意。

权限声明

module.json5 中声明需要的权限:

复制代码
ohos.permission.MICROPHONE

运行时请求

typescript 复制代码
// ChatPage.ets
async function grantPermission(): Promise<boolean> {
  const PERMISSIONS: Array<Permissions> = [
    'ohos.permission.MICROPHONE',
  ];
  try {
    // 获取应用程序的 accessTokenID
    let bundleInfo: bundleManager.BundleInfo =
      await bundleManager.getBundleInfoForSelf(
        bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
      );
    let appInfo: bundleManager.ApplicationInfo = bundleInfo.appInfo;
    let tokenId = appInfo.accessTokenId;
    let atManager = abilityAccessCtrl.createAtManager();
    let pems: Array<Permissions> = [];
    for (let i = 0; i < PERMISSIONS.length; i++) {
      let state = await atManager.checkAccessToken(tokenId, PERMISSIONS[i]);
      if (state !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        pems.push(PERMISSIONS[i]);
      }
    }
    if (pems.length > 0) {
      let result = await atManager.requestPermissionsFromUser(context, pems);
      let grantStatus: Array<number> = result.authResults;
      for (let i = 0; i < grantStatus.length; i++) {
        if (grantStatus[i] === 0) {
          // 用户授权
        } else {
          // 用户拒绝
          return false;
        }
      }
    }
    return true;
  } catch (e) {
    return false;
  }
}

权限请求的三步流程:

  1. 获取 Token ID :通过 bundleManager.getBundleInfoForSelf 获取应用的 accessTokenId,这是权限校验的标识
  2. 检查已授权状态atManager.checkAccessToken 检查权限是否已授予,避免重复弹窗
  3. 请求未授权权限atManager.requestPermissionsFromUser 弹出系统授权弹窗,用户选择后通过 authResults 判断结果

录音 UI 交互

ChatPage 中的录音 UI 采用按钮切换模式:

typescript 复制代码
Stack() {
  // 未录音状态:显示"点击说话"按钮
  Row() {
    Button('点击 说话', { stateEffect: true, type: ButtonType.Normal })
      .linearGradient({
        angle: 90,
        colors: [[0xFF33FF, 0.0], [0x1C55FF, 1]]
      })
      .borderRadius(30)
      .height(50)
      .width('100%')
      .onClick(async () => {
        grantPermission().then(async (isGranted: boolean) => {
          if (isGranted) {
            this.isShowRecord = true
            RecordUtils.getInstance().startRecordingProcess()
          } else {
            ToastUtil.showToast('录音权限未获取')
          }
        })
      });
  }
  .visibility(this.isShowRecord ? Visibility.None : Visibility.Visible)

  // 录音中状态:显示关闭、动画、发送按钮
  Row() {
    Image($r('app.media.ic_record_close')).width(30)
      .onClick(() => {
        this.isShowRecord = false
      })
    Lottie({
      controller: this.controller,
      animationPath: 'lottie/lottie_record.json',
      autoPlay: true,
      loop: true,
    }).width(90).height(90)

    Image($r('app.media.ic_record_send')).width(30)
      .onClick(() => {
        this.isShowRecord = false
        RecordUtils.getInstance().stopRecordingProcess().then((voicePath: string) => {
          this.chatModel.uploadFile(voicePath, (downloadUrl: string) => {
            let uuid = util.generateRandomUUID()
            this.chatModel.voiceRecognition(uuid, this.uid, downloadUrl).then((status) => {
              if (status === LoadingStatus.SUCCESS) {
                this.speechId = setInterval(() => {
                  this.speechQuery(uuid);
                }, 1100)
              }
            })
          })
        })
      })
  }
  .visibility(this.isShowRecord ? Visibility.Visible : Visibility.None)
}

UI 状态流转:

  • 默认:显示渐变色"点击说话"按钮
  • 录音中:隐藏按钮,显示三联操作区(关闭 | Lottie动画 | 发送)
  • Lottie动画:录音时播放声波动画,提供视觉反馈

录音到识别的完整链路

用户点击发送后的完整流程:

复制代码
停止录音 → 获取文件路径 → 上传到云存储 → 获取下载URL → 提交语音识别 → 轮询识别结果 → 转为文本 → 发送给AI
typescript 复制代码
RecordUtils.getInstance().stopRecordingProcess().then((voicePath: string) => {
  // 1. 上传录音文件到云存储
  this.chatModel.uploadFile(voicePath, (downloadUrl: string) => {
    // 2. 将录音URL提交给语音识别API
    let uuid = util.generateRandomUUID()
    this.chatModel.voiceRecognition(uuid, this.uid, downloadUrl).then((status) => {
      if (status === LoadingStatus.SUCCESS) {
        // 3. 删除云侧录音文件(节省存储)
        let cloudPath = 'voice/' + voicePath.split('/').pop() as string;
        this.chatModel.deleteFile(cloudPath)
        // 4. 轮询识别结果
        this.speechId = setInterval(() => {
          this.speechQuery(uuid);
        }, 1100)
      }
    })
  })
})

关键细节:

  • 识别成功后立即删除云侧录音文件,不保留用户语音数据
  • util.generateRandomUUID() 生成唯一标识,用于关联识别请求与结果
  • 轮询间隔 1100ms,与 AI 对话的 retrieve 轮询一致

语音识别结果处理

typescript 复制代码
private speechQuery(uuid: string) {
  this.chatModel.voiceQuery(uuid).then((data) => {
    clearInterval(this.speechId)
    let speechData = JSON.parse(data.toString()) as SpeechData
    let uerSpeechText = speechData.result?.text!!
    let chatData = new ChatData(uerSpeechText, '', Role.USER, 'text', 'success')
    this.chatList.push(chatData)
    this.listScroller.scrollEdge(Edge.Bottom);
    this.sendMsgToAgent(uerSpeechText, this.name);
  }).catch((err: BusinessError) => {
    clearInterval(this.speechId)
    ToastUtil.showToast('没听清楚,请再说一次吧')
  });
}

SpeechData 的结构:

typescript 复制代码
export class SpeechData {
  audio_info?: SpeechDataAudio_info = new SpeechDataAudio_info();
  result?: SpeechDataResult = new SpeechDataResult();
  code?: number = 200;
}

export class SpeechDataResult {
  additions?: SpeechDataResultAdditions = new SpeechDataResultAdditions();
  text?: string = "";
  utterances?: SpeechDataResultUtterances[] = [];
}

result.text 是识别出的完整文本,utterances 包含逐句的详细识别结果(含时间戳和置信度)。项目只使用 text 字段作为用户输入发送给 AI。

小结

本篇详细介绍了柚兔学伴的录音功能实现:

  • AVRecorder API:完整的录音生命周期管理------创建、配置、开始、暂停、停止、释放
  • fd:// 协议:通过文件描述符传递录音数据,HarmonyOS 特有的文件访问方式
  • AAC/M4A 配置:48kHz 采样率、双声道、100kbps 比特率,满足语音识别精度需求
  • 运行时权限:三步流程------获取 TokenID → 检查已授权 → 请求未授权权限
  • 录音 UI:按钮切换 + Lottie 声波动画,提供直观的录音反馈
  • 完整链路:录音 → 上传 → 识别 → 轮询 → 文本 → AI 对话,实现语音到对话的无缝衔接
相关推荐
l134062082351 小时前
HarmonyOS应用开发实战:小事记 - 多媒体文件上传:@ohos.net.http 的 multipart/form-data 请求构造
后端·华为·harmonyos·鸿蒙系统
xd1855785551 小时前
睡眠质量评估 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙
Jcc2 小时前
鸿蒙 Navigation 模块化实践
harmonyos
woshihuanglaoshi2 小时前
参数验证_Flutter在鸿蒙平台确保路由参数类型安全
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
绝世番茄2 小时前
鸿蒙原生 ArkTS 布局之 List 的单选与多选模式实战指南
华为·list·harmonyos·鸿蒙
胡琦博客2 小时前
HarmonyOS 智能工具箱(三):语音交互工具
交互·xcode·harmonyos
国服第二切图仔2 小时前
HarmonyOS7新特性之沉浸光感:新一代材质渲染技术解析
harmonyos
●VON3 小时前
鸿蒙 PC Markdown 编辑器链接补全与离线校验:中文路径、标题锚点与授权边界
华为·编辑器·harmonyos·鸿蒙
kiros_wang3 小时前
鸿蒙性能优化全维度实战(启动速度 + 内存治理 + 帧率稳定 + 包体积瘦身)
华为·性能优化·harmonyos