第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;
}
关键步骤:
- 以时间戳命名文件,避免冲突:
audio_1700000000000.m4a - 使用
fs.openSync以读写+创建模式打开文件 - 将文件描述符
fd转换为fd://协议路径 - 将
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
}
}
状态流转:createAVRecorder → prepare → start,对应 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!!
}
停止流程严格遵循:stop → release → closeSync。释放 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;
}
}
权限请求的三步流程:
- 获取 Token ID :通过
bundleManager.getBundleInfoForSelf获取应用的accessTokenId,这是权限校验的标识 - 检查已授权状态 :
atManager.checkAccessToken检查权限是否已授予,避免重复弹窗 - 请求未授权权限 :
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 对话,实现语音到对话的无缝衔接