You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Kotlin实现AudioRecord按钮松手停录、存wav、传服务及音频块播放问题

问题原因分析

你出现卡死的核心原因是:OnTouchListener的回调运行在主线程(UI线程),直接在ACTION_DOWN里写死循环会完全阻塞主线程,后续所有UI事件包括ACTION_UP都无法被分发处理,自然不可能触发停止逻辑。


完整解决方案

1. 正确实现录音+流式发送的启停逻辑

首先添加全局状态控制变量,把录音、发送逻辑放到子线程执行,避免阻塞主线程:

// 全局变量,加@Volatile保证子线程可以及时读取到状态变化
@Volatile
private var isRecording = false
private var recordThread: Thread? = null
private lateinit var audioBuffer: ByteArray

触摸事件逻辑修改如下:

btn_send.setOnTouchListener(object : View.OnTouchListener {
    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                Log.i("tou7ch", "apertou")
                isRecording = true
                audioBuffer = ByteArray(minBufSize)
                audiorecord!!.startRecording()
                // 启动子线程处理录音和发送
                recordThread = Thread {
                    while (isRecording) {
                        val readSize = audiorecord!!.read(audioBuffer, 0, minBufSize)
                        if (readSize > 0) {
                            // 只发送实际读取到的有效数据
                            val chunk = audioBuffer.copyOfRange(0, readSize).toByteString()
                            webSocket.send(chunk)
                            // 同时缓存数据到本地,后续生成WAV用
                            savePcmToCache(chunk.toByteArray())
                        }
                    }
                    // 循环结束后释放录音资源
                    audiorecord?.apply {
                        stop()
                        release()
                    }
                    audiorecord = null
                    // 生成最终WAV文件
                    generateWavFile()
                }
                recordThread?.start()
            }
            MotionEvent.ACTION_UP -> {
                Log.i("tou7ch", "soltou")
                // 修改状态标记,终止子线程循环
                isRecording = false
                // 等待线程结束避免内存泄漏
                try {
                    recordThread?.join(1000)
                } catch (e: InterruptedException) {
                    e.printStackTrace()
                }
                recordThread = null
            }
        }
        // 返回true表示消费当前触摸事件,避免后续事件丢失
        return true
    }
})

2. 保存为完整WAV格式文件

WAV文件本质是PCM裸数据前加44字节的格式头,实现逻辑如下:

// 全局变量,用于缓存PCM数据
private var cachePcmFile: File? = null
private var cacheOutputStream: FileOutputStream? = null

// 录制过程中缓存PCM数据
private fun savePcmToCache(buffer: ByteArray) {
    try {
        if (cacheOutputStream == null) {
            cachePcmFile = File(cacheDir, "temp_${System.currentTimeMillis()}.pcm")
            cacheOutputStream = FileOutputStream(cachePcmFile)
        }
        cacheOutputStream?.write(buffer)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

// 录制结束后生成WAV文件
private fun generateWavFile(): File? {
    cacheOutputStream?.flush()
    cacheOutputStream?.close()
    cacheOutputStream = null
    val pcmFile = cachePcmFile ?: return null
    // 输出WAV文件路径
    val wavFile = File(getExternalFilesDir(Environment.DIRECTORY_MUSIC), "record_${System.currentTimeMillis()}.wav")
    return try {
        // 以下参数必须和你创建AudioRecord时的参数完全一致
        val sampleRate = 16000 // 替换为实际采样率
        val channels = 1 // 单声道传1,双声道传2
        val bitWidth = 16 // 16位PCM传16,8位传8
        val pcmSize = pcmFile.length()
        val wavTotalSize = pcmSize + 36

        // 组装44字节WAV头
        val header = ByteArray(44)
        // RIFF标识
        header[0] = 'R'.code.toByte()
        header[1] = 'I'.code.toByte()
        header[2] = 'F'.code.toByte()
        header[3] = 'F'.code.toByte()
        // 总文件大小
        header[4] = (wavTotalSize and 0xff).toByte()
        header[5] = (wavTotalSize shr 8 and 0xff).toByte()
        header[6] = (wavTotalSize shr 16 and 0xff).toByte()
        header[7] = (wavTotalSize shr 24 and 0xff).toByte()
        // WAVE标识
        header[8] = 'W'.code.toByte()
        header[9] = 'A'.code.toByte()
        header[10] = 'V'.code.toByte()
        header[11] = 'E'.code.toByte()
        // fmt子块标识
        header[12] = 'f'.code.toByte()
        header[13] = 'm'.code.toByte()
        header[14] = 't'.code.toByte()
        header[15] = ' '.code.toByte()
        // 格式长度(PCM固定为16)
        header[16] = 16
        header[17] = 0
        header[18] = 0
        header[19] = 0
        // 编码格式(PCM固定为1)
        header[20] = 1
        header[21] = 0
        // 声道数
        header[22] = channels.toByte()
        header[23] = 0
        // 采样率
        header[24] = (sampleRate and 0xff).toByte()
        header[25] = (sampleRate shr 8 and 0xff).toByte()
        header[26] = (sampleRate shr 16 and 0xff).toByte()
        header[27] = (sampleRate shr 24 and 0xff).toByte()
        // 码率 = 采样率*声道数*位深/8
        val byteRate = sampleRate * channels * bitWidth / 8
        header[28] = (byteRate and 0xff).toByte()
        header[29] = (byteRate shr 8 and 0xff).toByte()
        header[30] = (byteRate shr 16 and 0xff).toByte()
        header[31] = (byteRate shr 24 and 0xff).toByte()
        // 块对齐 = 声道数*位深/8
        val blockAlign = channels * bitWidth / 8
        header[32] = blockAlign.toByte()
        header[33] = 0
        // 位深
        header[34] = bitWidth.toByte()
        header[35] = 0
        // data子块标识
        header[36] = 'd'.code.toByte()
        header[37] = 'a'.code.toByte()
        header[38] = 't'.code.toByte()
        header[39] = 'a'.code.toByte()
        // PCM数据大小
        header[40] = (pcmSize and 0xff).toByte()
        header[41] = (pcmSize shr 8 and 0xff).toByte()
        header[42] = (pcmSize shr 16 and 0xff).toByte()
        header[43] = (pcmSize shr 24 and 0xff).toByte()

        // 写入WAV头和PCM数据
        val wavOutputStream = FileOutputStream(wavFile)
        wavOutputStream.write(header)
        val pcmInputStream = FileInputStream(pcmFile)
        val buffer = ByteArray(1024)
        var len: Int
        while (pcmInputStream.read(buffer).also { len = it } != -1) {
            wavOutputStream.write(buffer, 0, len)
        }
        pcmInputStream.close()
        wavOutputStream.flush()
        wavOutputStream.close()
        // 删除临时PCM文件
        pcmFile.delete()
        wavFile
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

生成的WAV文件可以直接发送给服务器使用。

3. 接收端播放流式音频块

接收端使用AudioTrack即可实现流式播放,参数必须和录制端完全一致:

// 初始化AudioTrack,参数和录制端完全一致
val sampleRate = 16000
val channelConfig = AudioFormat.CHANNEL_OUT_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat)
val audioTrack = AudioTrack(
    AudioManager.STREAM_MUSIC,
    sampleRate,
    channelConfig,
    audioFormat,
    minBufferSize,
    AudioTrack.MODE_STREAM
)
// 启动播放
audioTrack.play()

// WebSocket收到音频块时直接写入AudioTrack
webSocket.setListener(object : WebSocketListener() {
    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
        super.onMessage(webSocket, bytes)
        val audioData = bytes.toByteArray()
        audioTrack.write(audioData, 0, audioData.size)
    }
})

// 播放结束后释放资源
// audioTrack.stop()
// audioTrack.release()

内容的提问来源于stack exchange,提问作者moigamijunior

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.08 21:15:02