录音在HarmonyOS里分两层:底层的AudioCapturer直接操作PCM数据,上层的media.createSoundPool做短音效。语音消息、会议记录用AudioCapturer,按钮点击音效用SoundPool。这篇把两条线都走通。
权限
录音需要MICROPHONE权限:
// module.json5 { "name": "ohos.permission.MICROPHONE" }动态申请不能省:
import{abilityAccessCtrl,common}from'@kit.AbilityKit';asyncrequestMic(context:common.UIAbilityContext):Promise<boolean>{letatManager:abilityAccessCtrl.AccessManager=abilityAccessCtrl.createAtManager();letresult=awaitatManager.requestPermissionsFromUser(context,['ohos.permission.MICROPHONE']);returnresult.authResults[0]===0;}没授权就调录音API直接报201权限错误。
AudioCapturer基础
import{audio}from'@kit.AudioKit';letaudioCapturer:audio.AudioCapturer=awaitaudio.createAudioCapturer({samplingRate:audio.AudioSamplingRate.SAMPLE_RATE_44100,channels:audio.AudioChannel.CHANNEL_1,sampleFormat:audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,encodingType:audio.AudioEncodingType.ENCODING_TYPE_RAW});四个参数决定PCM数据的格式:
- samplingRate:采样率,44100是CD品质,16000够语音识别用
- channels:1单声道2立体声,语音消息用单声道就行
- sampleFormat:S16LE表示16位小端,最通用的格式
- encodingType:RAW表示PCM裸数据
录音状态机
AudioCapturer有五个状态:
CREATED → PREPARED → RUNNING → PAUSED → STOPPED每次状态转换都有对应方法:
awaitaudioCapturer.prepare();// CREATED → PREPAREDawaitaudioCapturer.start();// PREPARED → RUNNINGawaitaudioCapturer.pause();// RUNNING → PAUSEDawaitaudioCapturer.stop();// RUNNING/PAUSED → STOPPEDawaitaudioCapturer.release();// 任何状态 → RELEASED从PAUSED恢复用start()不是resume()。从STOPPED回到RUNNING要重新prepare + start。
读取PCM数据
letbufferSize:number=awaitaudioCapturer.getBufferSize();letbuffer:ArrayBuffer=newArrayBuffer(bufferSize);asyncfunctionreadLoop():Promise<void>{while(audioCapturer.state===audio.AudioState.STATE_RUNNING){letreadSize:number=awaitaudioCapturer.read(buffer);if(readSize>0){// 处理PCM数据: buffer的前readSize字节processPcmData(buffer,readSize);}}}read()是异步的,返回实际读取的字节数。readSize可能小于bufferSize——这是正常的,尤其在录音刚开始时。
读取循环通常跑在单独的任务里,避免阻塞UI。HarmonyOS推荐用TaskPool:
import{taskpool}from'@kit.ArkTS';@ConcurrentfunctioncaptureTask(capturer:audio.AudioCapturer):void{// 读取循环}taskpool.execute(captureTask,audioCapturer);但AudioCapturer是Native对象,不能直接跨线程传递。实际操作中更常见的方式是用setInterval定时读取。
录音回调方式
除了主动read,还可以用回调方式获取数据:
audioCapturer.on('readData',(buffer:ArrayBuffer)=>{// buffer就是PCM数据processPcmData(buffer,buffer.byteLength);});回调方式更简洁,不用自己管读取循环。但回调频率很高(每秒几十次),处理函数要尽量轻。
音量监听
audioCapturer.on('markReach',1000,(position:number)=>{// 每采集1000帧触发一次});// 实时音量(用于波形显示)audioCapturer.on('periodReach',200,(position:number)=>{// 每200帧触发});要获取实时音量做波形显示,需要在readData回调里计算PCM数据的RMS值:
functioncalculateRms(buffer:ArrayBuffer,readSize:number):number{letview:Int16Array=newInt16Array(buffer);letsum:number=0;letcount:number=Math.floor(readSize/2);for(leti=0;i<count;i++){letsample:number=view[i];sum+=sample*sample;}letrms:number=Math.sqrt(sum/count);returnrms/32768;// 归一化到0-1}Int16Array对应S16LE格式。32768是16位最大值,归一化后RMS在0-1范围。
保存为WAV文件
PCM数据保存为WAV需要加文件头:
import{fileIoasfs}from'@kit.CoreFileKit';functionwriteWavHeader(fd:number,sampleRate:number,channels:number,bitsPerSample:number,dataLength:number):void{letheaderSize:number=44;letheader:ArrayBuffer=newArrayBuffer(headerSize);letview:DataView=newDataView(header);// RIFF chunkview.setUint8(0,0x52);view.setUint8(1,0x49);view.setUint8(2,0x46);view.setUint8(3,0x46);view.setUint32(4,36+dataLength,true);view.setUint8(8,0x57);view.setUint8(9,0x41);view.setUint8(10,0x56);view.setUint8(11,0x45);// fmt chunkview.setUint8(12,0x66);view.setUint8(13,0x6D);view.setUint8(14,0x74);view.setUint8(15,0x20);view.setUint32(16,16,true);// chunk sizeview.setUint16(20,1,true);// PCM formatview.setUint16(22,channels,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*channels*bitsPerSample/8,true);// byte rateview.setUint16(32,channels*bitsPerSample/8,true);// block alignview.setUint16(34,bitsPerSample,true);// data chunkview.setUint8(36,0x64);view.setUint8(37,0x61);view.setUint8(38,0x74);view.setUint8(39,0x61);view.setUint32(40,dataLength,true);fs.writeSync(fd,header);}WAV文件头固定44字节。先写头再写PCM数据。但有个问题:录音开始时不知道总数据长度,所以通常先写44字节的占位头,录完后seek回文件头更新dataLength。
// 开始录音时letfd:number=fs.openSync(filePath,fs.OpenMode.CREATE|fs.OpenMode.READ_WRITE);writeWavHeader(fd,44100,1,16,0);// dataLength先写0// 录音过程中追加数据lettotalDataLength:number=0;audioCapturer.on('readData',(buffer:ArrayBuffer)=>{fs.writeSync(fd,buffer);totalDataLength+=buffer.byteLength;});// 停止录音后fs.lseekSync(fd,4,0);// 偏移到RIFF size字段letview:DataView=newDataView(newArrayBuffer(4));view.setUint32(0,36+totalDataLength,true);fs.writeSync(fd,view.buffer);// 更新RIFF sizefs.lseekSync(fd,40,0);// 偏移到data size字段letview2:DataView=newDataView(newArrayBuffer(4));view2.setUint32(0,totalDataLength,true);fs.writeSync(fd,view2.buffer);// 更新data sizefs.closeSync(fd);录音时长计算
letrecordedDuration:number=0;// 毫秒audioCapturer.on('readData',(buffer:ArrayBuffer)=>{letbytesPerSample:number=2;// S16LE = 2 bytesletchannels:number=1;letsampleRate:number=44100;letbytesPerMs:number=sampleRate*channels*bytesPerSample/1000;recordedDuration+=buffer.byteLength/bytesPerMs;});每收到一段PCM数据,根据字节长度和采样参数计算对应时长。累加就是总录音时长。
最大录音时长限制
letmaxDurationMs:number=60000;// 60秒audioCapturer.on('readData',(buffer:ArrayBuffer)=>{this.recordedDuration+=buffer.byteLength/bytesPerMs;if(this.recordedDuration>=maxDurationMs){audioCapturer.stop();}});在readData回调里检查累计时长,超过限制自动停止。微信语音消息60秒就是这么实现的。
录音状态监听
audioCapturer.on('stateChange',(state:audio.AudioState)=>{switch(state){caseaudio.AudioState.STATE_PREPARED:// 准备完成break;caseaudio.AudioState.STATE_RUNNING:// 录音中break;caseaudio.AudioState.STATE_PAUSED:// 暂停break;caseaudio.AudioState.STATE_STOPPED:// 已停止break;caseaudio.AudioState.STATE_RELEASED:// 已释放break;}});UI状态通常根据stateChange回调更新,比如录音按钮的图标切换。
资源释放
录音结束后必须释放资源:
asyncreleaseCapturer():Promise<void>{if(this.audioCapturer){if(this.audioCapturer.state===audio.AudioState.STATE_RUNNING){awaitthis.audioCapturer.stop();}awaitthis.audioCapturer.release();this.audioCapturer=undefined;}}在aboutToDisappear中调用releaseCapturer。不释放的话其他应用无法录音。
SoundPool——短音效播放
录音的对立面是播放。SoundPool专门用于短音效,加载快、延迟低:
import{media}from'@kit.MultimediaKit';letsoundPool:media.SoundPool=awaitmedia.createSoundPool(5,audio.AudioRendererInfo());letsoundId:number=awaitsoundPool.load('/data/storage/el2/base/haps/entry/files/click.wav');letstreamId:number=awaitsoundPool.play(soundId);createSoundPool的第一个参数是最大并发流数,5表示最多同时播放5个音效。load返回soundId,play返回streamId。
// 停止特定音效soundPool.stop(streamId);// 设置音量soundPool.setVolume(streamId,1.0,1.0);// 左右声道// 释放soundPool.release();SoundPool适合按钮点击、游戏音效等场景。长音频用AVPlayer。
音频焦点
录音时如果其他应用在播放音频,需要请求音频焦点:
letaudioManager:audio.AudioManager=audio.getAudioManager();letaudioSessionManager:audio.AudioSessionManager=audioManager.getSessionManager();awaitaudioSessionManager.activate(audio.AudioSessionStrategy.create({concurrency:audio.AudioConcurrency.EXCLUSIVE}));EXCLUSIVE表示独占——录音时其他音频暂停。CONCURRENCY_MIX表示混音。
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| createAudioCapturer报201 | 缺MICROPHONE权限 | 动态申请 |
| start()报错 | 没先prepare | 按CREATED→PREPARED→RUNNING顺序 |
| read返回0 | capturer没在RUNNING状态 | 检查state再read |
| WAV文件播放不了 | 文件头dataLength不对 | 录完后seek更新头 |
| 录音时长不准 | bytesPerMs计算错误 | 检查sampleRate和channels |
| 其他应用无法录音 | 没release | aboutToDisappear释放 |
| SoundPool load失败 | 文件路径不对 | 检查文件是否存在 |
| 录音有噪音 | 采样率太低或增益过高 | 44100采样率+适当增益 |
录音开发最关键的三个点:权限必须在录音前申请、状态机必须按顺序推进、PCM数据转WAV时文件头要正确。这三点搞对了,基本不会出大问题。