Android PCM录音实践
前言
本来弄着RemoteView的活,也差不多了,临时接了工作上的麦克风录音PCM格式的任务,正好我这hardware模块也需要这东西,就先抽出通用部分写了下,一来能实践,二来还能满足工作要求,下面就记录下。
需求
任务很简单,主要就是下面几个需求:
- 录制PCM音频
 - 将数据按1280字节分段储存
 - 播放PCM音频
 
就简单三项,其中第二项的1280字节储存是因为需要和讯飞对接,把数据实时传给它并获取文字,下面开干。
PCM录制
录制前先加下麦克风权限,以及动态申请吧,这里就不详细说了。
PCM音频实际就是模拟信号采样嘛,首先要看你需要的一些配置,采样率之类的:
            
            
              kotlin
              
              
            
          
              companion object {
        // 采样率
        const val SAMPLE_RATE = 16000
        // 单声道
        const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
        // PCM编码位数
        const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
    }
        然后拿着采样率、声道数量、PCM编码位数创建AudioRecord,启动startRecording,就能在它的输出流里面读取数据了,当然最好在线程里面读取。
            
            
              kotlin
              
              
            
          
          /**
 * 开始录音
 */
@SuppressLint("MissingPermission")
fun startCapture(executor: Executor) {
    // 计算下应该设置的bufferSize
    val minBufferSize = AudioRecord.getMinBufferSize(
        SAMPLE_RATE, 
        CHANNEL_CONFIG, 
        AUDIO_FORMAT
    )
    val bufferSize = if (minBufferSize > PCM_PACKET_SIZE) PCM_PACKET_SIZE else minBufferSize
    mAudioRecord = AudioRecord(
        MediaRecorder.AudioSource.MIC,
        SAMPLE_RATE, 
        CHANNEL_CONFIG,
        AUDIO_FORMAT, 
        bufferSize
    )
    val buffer = ByteArray(bufferSize)
    mAudioRecord!!.startRecording()
    mIsRecording = true
    // 使用自带线程池执行
    executor.execute {
        while (mIsRecording) {
            val bytesRead = mAudioRecord!!.read(buffer, 0, bufferSize)
            if (bytesRead > 0) {
                enqueueData(buffer.sliceArray(0 until bytesRead))
            }
        }
    }
}
/**
 * 结束录音
 */
fun stopCapture() {
    mIsRecording = false
    mAudioRecord?.stop()
    mAudioRecord?.release()
    mAudioRecord = null
}
        数据分段储存
上面写到了从AudioRecord的输出流读取数据,接下来就需要按1280字节给它分段,这里我用的queue:
            
            
              kotlin
              
              
            
          
          // 转换packet时多余的数据
private var mRemainingData: ByteArray = byteArrayOf()
// 数据按1280字节储存,每40ms向H5发送
private val mDataQueue: Queue<ByteArray> = ConcurrentLinkedQueue()
// 数据转换成1280字节的包
private fun enqueueData(data: ByteArray) {
    var combinedData = mRemainingData + data
    while (combinedData.isNotEmpty()) {
        val packetSize = minOf(PCM_PACKET_SIZE, combinedData.size)
        val packet = ByteArray(packetSize)
        System.arraycopy(combinedData, 0, packet, 0, packetSize)
        mDataQueue.offer(packet)
        combinedData = if (packetSize < combinedData.size) {
            combinedData.drop(packetSize).toByteArray()
        } else {
            byteArrayOf()
        }
    }
    mRemainingData = combinedData
}
/**
 * 读取封装成1280字节packet
 *
 * @return 封装成1280字节的packet
 */
fun dequeueData(): ByteArray? {
    return mDataQueue.poll()
}
        在enqueueData的时候,通过一个mRemainingData配合,把数据封成1280字节,再放到queue里面,这里用了个线程安全的queue,外部要数据从queue里面取就行了。
PCM播放
上面拿到数据我们保存一份到文件里面去,后面从文件流里面读取数据,放到audioTrack的缓冲流里面就可以播放了。
            
            
              kotlin
              
              
            
          
          import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.Executor
/**
 * PCM音频播放
 *
 * @author lfq
 * @date 2024-04-12
 */
class PcmPlayer {
    /**
     * 播放pcm音频
     *
     * @param file pcm音频文件
     * @param executor 线程池
     */
    fun play(file: File, executor: Executor) {
        // 计算下应该设置的bufferSize
        val bufferSize = AudioRecord.getMinBufferSize(
            PCMDataCapture.SAMPLE_RATE,
            PCMDataCapture.CHANNEL_CONFIG,
            PCMDataCapture.AUDIO_FORMAT
        )
        // builder需要兼容API23
        @Suppress("DEPRECATION")
        val audioTrack = AudioTrack(
            AudioManager.STREAM_MUSIC,
            PCMDataCapture.SAMPLE_RATE,
            // 注意这里用CHANNEL_OUT_MONO
            AudioFormat.CHANNEL_OUT_MONO,
            PCMDataCapture.AUDIO_FORMAT,
            bufferSize,
            AudioTrack.MODE_STREAM
        )
        // 开始播放,这一步要先执行
        audioTrack.play()
        executor.execute {
            // 读取数据到audioTrack缓存区
            FileInputStream(file).use { inputStream->
                val pcmData = ByteArray(bufferSize)
                var bytesRead: Int
                while (inputStream.read(pcmData).also { bytesRead = it } != -1) {
                    audioTrack.write(pcmData, 0, bytesRead)
                }
            }
            // 播放完释放资源
            audioTrack.stop()
            audioTrack.release()
        }
    }
}
        简单使用
为了配合讯飞40ms发一次消息的需求,我还加了一个Handler,来定时读取queue里面的数据:
            
            
              kotlin
              
              
            
          
          import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.Log
import androidx.core.util.Consumer
import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.Executor
/**
 * 定期发送pcm packet的主线程handler
 *
 * @author lfq
 * @date 2024-04-11
 */
class PcmHandler(
    private var executor: Executor,
    private var callback: Consumer<ByteArray>
): Handler(Looper.getMainLooper()) {
    companion object {
        // 开始录制
        const val MSG_TYPE_START = 0
        // 循环发送数据到H5
        const val MSG_TYPE_CONTINUE = 1
        // 结束录制
        const val MSG_TYPE_STOP = 2
        // H5接收PCM数据的间隔
        const val PCM_SEND_DELAY = 40L
        /**
         * 以追加形式保存packet到文件里面
         *
         * @param executor 线程池,用来写IO文件
         * @param
         */
        fun saveToFile(executor: Executor, packet: ByteArray, targetFile: File) {
            executor.execute {
                RandomAccessFile(targetFile, "rwd").use { raf->
                    raf.seek(targetFile.length())
                    raf.write(packet)
                }
            }
        }
    }
    // PCM音频录制器
    private var mPcmCapture: PCMDataCapture? = null
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)
        when(msg.what) {
            MSG_TYPE_START -> {
                // 启动录制
                mPcmCapture = PCMDataCapture().also {
                    it.startCapture(executor)
                }
                // 开启循环,接收数据
                Log.d("TAG", "handleMessage sendEmptyMessageDelayed: ")
                sendEmptyMessageDelayed(MSG_TYPE_CONTINUE, PCM_SEND_DELAY)
            }
            MSG_TYPE_CONTINUE -> {
                if (mPcmCapture == null) {
                    throw IllegalStateException("PCMDataCapture not start!")
                }
                val packet = mPcmCapture!!.dequeueData()
                // 向H5传递数据
                if (packet != null) {
                    callback.accept(packet)
                }
                // 定时发送缓存的packet队列数据
                if (packet != null || mPcmCapture!!.isRecording()) {
                    sendEmptyMessageDelayed(MSG_TYPE_CONTINUE, PCM_SEND_DELAY)
                }else {
                    callback.accept(null)
                }
            }
            MSG_TYPE_STOP -> {
                // 关闭录制
                mPcmCapture?.stopCapture()
            }
        }
    }
}
        代码很简单,定义了三种消息,我们要录音直接向handler发消息就行了,下面就简单使用下:
            
            
              kotlin
              
              
            
          
          // 处理PCM录制相关逻辑
private var mPcmHandler: PcmHandler? = null
private val mExecutor = Executors.newSingleThreadExecutor()
/**
 * 开始录制音频
 */
@SuppressLint("SetTextI18n")
private fun startPcmRecord() {
    val file = File(requireContext().externalCacheDir, "test.pcm")
    if (!file.exists()) {
        file.createNewFile()
    }
    FileUtil.clearFile(file.absolutePath)
    
    mPcmHandler = PcmHandler(mExecutor) {
        it?.let {
            PcmHandler.saveToFile(mExecutor, it, file)
        }
    }
    mPcmHandler!!.sendEmptyMessage(PcmHandler.MSG_TYPE_START)
}
/**
 * 结束录制音频
 */
private fun stopPcmRecord() {
    mPcmHandler!!.sendEmptyMessage(PcmHandler.MSG_TYPE_STOP)
    mPcmHandler = null
}
private fun playPcmRecord() {
    val file = File(requireContext().externalCacheDir, "test.pcm")
    PcmPlayer().play(file, mExecutor)
}
        很easy,不过千万别忘了stopPcmRecord,不然会一直录制,而且PCM格式的文件大小还挺大的,必须注意下。
源码及DEMO
源码及DEMO我也提交到我练手的仓库了,后续可能会有所修改,可以参考下:
小结
简单记录了一下利用麦克风录制PCM格式的音频,并通过AudioRecord播放。