【喵汪星球HarmonyOS 6.0】技术实战 07:AVRecorder 实现宠物叫声录音与本地分析

📅 2026/7/5 14:27:03
【喵汪星球HarmonyOS 6.0】技术实战 07:AVRecorder 实现宠物叫声录音与本地分析
前言“宠物翻译器”是喵汪星球里最有传播感的功能。但这个功能也最容易翻车如果文案写得太满用户会以为 App 真能百分百听懂猫狗如果技术链路太粗糙又会像一个假按钮。所以项目里的实现策略是用AVRecorder录制真实叫声保存到应用沙箱录音过程中做波形动效和峰值采样结束后基于时长、峰值、平均能量和宠物类型生成“可能意图、叫声特征、可信等级”。同时在 UI 中反复强调“仅供娱乐参考异常请及时就医”。这篇拆完整链路。效果图录音中状态按钮、波形、计时、颜色都会变化。录音结束后本机生成摘要、可能意图、叫声特征、可信等级并支持用户反馈“像/不像”。导入音频或试用样本时也走同一套结果展示链路。先定边界这不是医疗判断也不是精准翻译项目里有一条固定提示叫声翻译仅供娱乐参考不能代表宠物真实想法或替代兽医判断这不是“保守文案”而是产品边界。宠物叫声受环境、距离、设备麦克风、宠物个体差异影响很大。首版做成“本地基础分类版”更稳不依赖网络符合离线优先定位。先打通录音、导入、结果、历史、反馈链路。结果使用“可能”“参考等级”表达。后续可以替换为真正的离线模型。技术上先把链路做真文案上不夸大能力这是这类功能能上线的前提。录音状态机先看整体状态流mermaid stateDiagram-v2 [*] -- Idle: 等待录音 Idle -- PermissionPending: 点击开始录音 PermissionPending -- Idle: 权限拒绝 PermissionPending -- Preparing: 权限通过 Preparing -- Recording: AVRecorder prepare start Recording -- Analyzing: 点击停止并翻译 Analyzing -- Done: 生成本地分析 Recording -- Idle: 启动失败 / 页面退出 Done -- Idle: 再次录音 对应到代码里的状态State isRecording: boolean false State recordingVisualActive: boolean false State recordingPermissionPending: boolean false State recordingSeconds: number 0 State recordingPulse: number 0 State recordingAmplitudeLevel: number 0 State translatorStatus: string 等待录音 State translatorError: string private avRecorder: media.AVRecorder | null null private recordingFile: fileIo.File | null null private recordingFilePath: string private speechTimer: number -1 private waveTimer: number -1 private amplitudeTimer: number -1 private recordingStartedAt: number 0 private recordingAmplitudeSum: number 0 private recordingAmplitudeSamples: number 0 private recordingPeakAmplitude: number 0这里把“底层录音状态”和“UI 录音状态”拆开了isRecording底层 recorder 是否已经启动。recordingVisualActive界面是否展示录音中。recordingPermissionPending是否正在申请权限用于防重复点击。音频功能最怕重复点击和异步状态竞争状态拆细一点后面的异常处理会轻松很多。入口一个按钮控制开始和结束录音按钮调用toggleRecording()private async toggleRecording(): Promisevoid { if (this.recordingPermissionPending) { return } if (!this.recordingVisualActive) { await this.startLocalVoiceRecording() } else { await this.finishLocalVoiceRecording() } }按钮文案也由状态驱动private recordingButtonText(): string { if (this.recordingPermissionPending) { return 申请权限中 } return this.recordingVisualActive ? 停止并翻译 : 开始录音 }这样用户在权限弹窗或录音初始化期间无法重复触发避免创建多个 recorder。麦克风权限只在用户触发时申请项目在module.json5中声明麦克风权限并在用户点击开始录音时申请private async ensureMicrophonePermission(): Promiseboolean { try { const context this.getUIContext().getHostContext() as common.UIAbilityContext const manager abilityAccessCtrl.createAtManager() const permissions: ArrayPermissions [ohos.permission.MICROPHONE] const result await manager.requestPermissionsFromUser(context, permissions) return result.authResults.length 0 result.authResults[0] abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED } catch (err) { return false } }被拒绝时所有状态都回到可操作状态if (!granted) { this.isRecording false this.recordingVisualActive false this.stopRecordTimer() this.translatorStatus 需要麦克风权限 this.translatorError 请允许麦克风权限后再录音。 this.saveState() return }音频权限失败不是异常路径而是正常用户路径。用户可能就是不想授权App 也要能继续展示导入音频、试用样本等替代入口。初始化 AVRecorder开始录音前先重置上一次结果this.translatorDone false this.translatorFeedback this.selectedVoiceSampleIndex -1 this.recognizedSpeechText this.translatorResultText 未识别到清晰语音 this.translatorResultIntent 寻求关注 / 想互动 this.translatorResultFeature 未捕捉到稳定语音片段 this.translatorResultConfidence 参考等级 40% this.recordingSeconds 0 this.recordingAmplitudeSum 0 this.recordingAmplitudeSamples 0 this.recordingPeakAmplitude 0然后创建沙箱录音文件const context this.getUIContext().getHostContext() as common.UIAbilityContext const fileName pet_voice_ Date.now().toString() .m4a this.recordingFilePath context.filesDir / fileName this.lastRecordingFileName fileName this.recordingFile fileIo.openSync( this.recordingFilePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC )录音 profileconst profile: media.AVRecorderProfile { audioBitrate: 64000, audioChannels: 1, audioCodec: media.CodecMimeType.AUDIO_AAC, audioSampleRate: 44100, fileFormat: media.ContainerFormatType.CFT_MPEG_4A } const config: media.AVRecorderConfig { audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC, profile: profile, url: fd:// this.recordingFile.fd.toString() } this.avRecorder await media.createAVRecorder() await this.avRecorder.prepare(config) await this.avRecorder.start()几个点很关键文件保存到context.filesDir属于应用沙箱。使用 AAC MPEG_4A保存为 m4a。url使用fd://加文件描述符。一定是prepare成功后再start。如果设备或模拟器不支持录音catch 中要释放资源并反馈catch (err) { this.isRecording false this.recordingVisualActive false this.stopRecordTimer() await this.releaseRecorder() this.translatorStatus 录音启动失败 this.translatorError 当前设备或模拟器暂时无法启动麦克风录音。 this.saveState() }波形动画视觉反馈和音频采样分开录音时有三个定时器private startRecordTimer(): void { this.stopRecordTimer() this.speechTimer setInterval(() { if (this.recordingVisualActive) { this.recordingSeconds this.recordingSeconds 1 } }, 1000) this.waveTimer setInterval(() { if (this.recordingVisualActive) { this.recordingPulse (this.recordingPulse 1) % 12 } }, 260) this.amplitudeTimer setInterval(() { if (this.recordingVisualActive this.avRecorder ! null) { this.captureRecorderAmplitude() } }, 320) }三个 timer 分别负责speechTimer秒数。waveTimer波形节奏。amplitudeTimer真实峰值采样。波形高度根据recordingPulse计算private waveBarHeight(baseHeight: number, index: number): number { if (!this.recordingVisualActive) { return baseHeight } const phase (this.recordingPulse index * 2) % 12 const distance Math.abs(phase - 6) const lift (6 - distance) * 6 const adjusted baseHeight lift - 10 if (adjusted 24) { return 24 } if (adjusted 88) { return 88 } return adjusted }这里的波形是视觉反馈不假装是精确声波。真实声学数据来自getAudioCapturerMaxAmplitude()。峰值采样让结果不是固定文案采样方法private async captureRecorderAmplitude(): Promisevoid { if (this.avRecorder null) { return } try { const amplitude await this.avRecorder.getAudioCapturerMaxAmplitude() if (amplitude this.recordingPeakAmplitude) { this.recordingPeakAmplitude amplitude } this.recordingAmplitudeSum this.recordingAmplitudeSum amplitude this.recordingAmplitudeSamples this.recordingAmplitudeSamples 1 this.recordingAmplitudeLevel Math.min(100, Math.floor(amplitude / 327)) } catch (err) { } }最终分析用三个指标duration录音时长。averageAmplitude平均能量。peakAmplitude峰值能量。这让本地分析有一定“现场感”。比如低能量、样本很短时会提示靠近宠物重新录制而不是硬给一个高可信结论。停止录音加一个最短分析延迟停止时先调用 recorderprivate async finishLocalVoiceRecording(): Promisevoid { this.translatorStatus 正在生成结果 try { if (this.isRecording this.avRecorder ! null) { await this.avRecorder.stop() } } catch (err) { } this.completeLocalVoiceRecognitionWithMinimumDelay() }项目做了一个最短 1.6 秒的分析延迟private completeLocalVoiceRecognitionWithMinimumDelay(): void { const elapsed Date.now() - this.recordingStartedAt if (elapsed 1600) { setTimeout(() { if (this.recordingVisualActive) { this.completeLocalVoiceRecognition() } }, 1600 - elapsed) return } this.completeLocalVoiceRecognition() }这个细节很小但体验差别很明显。用户如果刚开始就停止立刻出现“分析完成”会显得像假结果同时过短音频也很可能拿不到有效采样。本地分析规则核心规则在buildVoiceAnalysis()private buildVoiceAnalysis( duration: number, averageAmplitude: number, peakAmplitude: number ): VoiceAnalysis { const petType this.hasPetProfile ? (this.petTypeDraft 狗 ? 狗狗 : 猫咪) : 宠物 const energy peakAmplitude 22000 ? 强 : (peakAmplitude 9000 ? 中 : 弱) const rhythm duration 5 ? 拖长或持续 : (duration 2 ? 短促 : 短句重复) const description petType 叫声约 duration.toString() 秒音量能量 energy 节奏偏 rhythm }低能量样本直接降可信if (peakAmplitude 1200 || averageAmplitude 500) { return { description: description 有效叫声较少, intent: 信息不足建议靠近宠物重新录制, feature: 低能量 · 可能是环境声 · 样本偏短, confidence: 较低 35% } }犬类规则示例if (this.petTypeDraft 狗) { if (peakAmplitude 22000 duration 3) { return { description: description, intent: 可能在提醒外部动静或表达兴奋, feature: 短吠 · 音量高 · 起声明确, confidence: 中高 78% } } }猫类规则示例if (peakAmplitude 22000 duration 3) { return { description: description, intent: 可能抗拒当前环境或被突然刺激吓到, feature: 尖细 · 急促 · 强度波动, confidence: 中高 80% } }这不是模型推理但它把“宠物类型、声音强度、时长节奏、可信等级”组织成了可迭代的规则层。以后接入离线模型时只要替换buildVoiceAnalysis()或在它前面增加模型结果即可。音频导入和内置样本真实用户不一定会现场录音所以项目还支持音频导入private async importAudioFromDevice(): Promisevoid { try { const context this.getUIContext().getHostContext() as common.UIAbilityContext const options new picker.AudioSelectOptions() options.maxSelectNumber 1 const audioPicker new picker.AudioViewPicker(context) const uris await audioPicker.select(options) if (uris.length 0) { return } this.applyImportedAudio(uris[0]) } catch (err) { this.translatorStatus 导入失败 this.translatorError 请选择 wav、m4a、mp3 或 aac 格式的音频文件。 this.saveState() } }没有录音时还能试用内置样本private useDemoVoiceSample(): void { this.importRandomVoiceSample() }这对演示和新用户很有用。用户没准备好宠物叫声也能理解功能输出长什么样。历史记录和反馈完成分析后写入历史private addTranslationHistory(feedback: string): void { const record: TranslationHistory { id: Date.now().toString(), petName: this.hasPetProfile this.selectedPetName ! ? this.selectedPetName : 未建档宠物, intent: this.translatorIntentText(), confidence: this.translatorConfidenceText(), feedback: feedback } this.translationHistory [record].concat(this.translationHistory).slice(0, 5) }只保留最近 5 条是一个很好的 MVP 取舍用户有历史感但本地状态不会无限膨胀。反馈“像/不像”可以更新最近一条private updateLatestTranslationFeedback(feedback: string): void { if (this.translationHistory.length 0) { this.addTranslationHistory(feedback) return } this.translationHistory this.translationHistory.map( (item: TranslationHistory, index: number) { if (index 0) { return { id: item.id, petName: item.petName, intent: item.intent, confidence: item.confidence, feedback: feedback } } return item } ) }哪怕首版没有训练模型反馈字段也有价值。它为后续优化数据结构预留了位置。资源释放录音功能必须认真收尾释放 recorder 和文件句柄private async releaseRecorder(): Promisevoid { if (this.avRecorder ! null) { try { await this.avRecorder.release() } catch (err) { } this.avRecorder null } if (this.recordingFile ! null) { try { fileIo.closeSync(this.recordingFile) } catch (err) { } this.recordingFile null } }页面消失时也要处理aboutToDisappear() { this.stopRecordTimer() this.releaseRecorder() }录音、相机、文件句柄这些系统资源不能靠“页面销毁后系统会处理”来赌。主动释放是最稳的。踩坑总结第一权限弹窗期间要防重复点击否则可能创建多个 recorder。第二fd://路径要和打开的文件描述符匹配录音结束后要 close 文件。第三模拟器或部分设备可能无法启动麦克风启动失败要给用户替代路径。第四录音中退出页面必须停止 timer 并释放 recorder。第五分析结果不要绝对化尤其不要把疼痛、疾病相关结果写成诊断。第六短录音要有兜底不要硬给高可信。本篇小结一个看起来很酷的“宠物翻译器”背后其实是很扎实的状态工程用明确状态机控制录音流程。用户触发时申请麦克风权限。用AVRecorder写入应用沙箱音频文件。视觉波形和真实峰值采样分开实现。用时长、峰值、平均能量和宠物类型做本地规则分析。保留历史和反馈为后续模型优化留接口。页面退出和异常分支都释放资源。下一篇不讲系统 API而讲内容型功能怎么做出产品感陪玩推荐、收藏、随机切换和玩法风险提示。