Android PCM录音实践

Android PCM录音实践

前言

本来弄着RemoteView的活,也差不多了,临时接了工作上的麦克风录音PCM格式的任务,正好我这hardware模块也需要这东西,就先抽出通用部分写了下,一来能实践,二来还能满足工作要求,下面就记录下。

需求

任务很简单,主要就是下面几个需求:

  1. 录制PCM音频
  2. 将数据按1280字节分段储存
  3. 播放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我也提交到我练手的仓库了,后续可能会有所修改,可以参考下:

PCMDataCapture

PcmHandler

PcmPlayer

PcmFragment

小结

简单记录了一下利用麦克风录制PCM格式的音频,并通过AudioRecord播放。

相关推荐
Geeker555 小时前
如何在忘记密码的情况下解锁Android手机?
android·网络·macos·华为·智能手机·电脑·手机
wxx21506 小时前
【android】【adb shell】写一个shell脚本,监听进程pid变化
android·adb
心死翼未伤7 小时前
【MySQL基础篇】多表查询
android·数据结构·数据库·mysql·算法
喂_balabala7 小时前
Android手机拍照或从本地相册选取图片设置头像-高版本适配
android·开发语言
_小马快跑_9 小时前
Android | StandardCharsets.UTF_8.toString() 遇到的兼容问题记录
android
wxx215010 小时前
【Android】【多屏】多屏异显异触调试技巧总结
android
人民的石头12 小时前
Android增量更新----java版
android
Geeker5513 小时前
如何在忘记密码的情况下删除华为ID激活锁
android·运维·服务器·网络·macos·华为·智能手机
XD74297163615 小时前
【Android】ADB 使用指南
android·adb
骨子里的偏爱15 小时前
uniapp/Android App上架三星市场需要下载所需要的SDK
android·uni-app