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

Android Kotlin:将RecognitionIntent用户音频保存至应用缓存文件夹

解决方案:Android跟读音频录制与WAV格式处理

核心问题分析

你遇到的问题本质是:

  • 系统SpeechRecognizer不支持直接返回录制音频:onBufferReceived仅在少数识别引擎中触发,ACTION_RECOGNIZE_SPEECH也不会返回音频URI,getData()返回null是正常行为。
  • PCM转WAV失败:WAV文件必须包含描述采样率、声道数等参数的文件头,仅写入PCM原始数据会导致播放器无法识别。

首选方案:用AudioRecord直接录制PCM并生成标准WAV

放弃依赖SpeechRecognizer,改用AudioRecord直接录制PCM原始数据,再手动添加正确的WAV文件头,确保输出格式符合musicg要求。

步骤1:实现AudioRecord录制逻辑

添加必要权限(已申请RECORD_AUDIO,若使用外部存储需额外申请WRITE_EXTERNAL_STORAGE),然后编写录制代码:

private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
private var isRecording = false
private var tempPcmFile: File? = null

// 注意:采样率、声道数要和TTS生成的WAV保持一致,这里以16000Hz单声道为例
private val sampleRate = 16000
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT

private fun startRecording() {
    val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
    audioRecord = AudioRecord(
        MediaRecorder.AudioSource.MIC,
        sampleRate,
        channelConfig,
        audioFormat,
        bufferSize
    )

    tempPcmFile = File(cacheDir, "temp_recording.pcm")
    val outputStream = FileOutputStream(tempPcmFile)

    audioRecord?.startRecording()
    isRecording = true

    recordingThread = Thread {
        val buffer = ByteArray(bufferSize)
        while (isRecording) {
            val readCount = audioRecord?.read(buffer, 0, bufferSize) ?: 0
            if (readCount > 0) {
                outputStream.write(buffer, 0, readCount)
            }
        }
        outputStream.close()
        // 将PCM转为标准WAV
        convertPcmToWav(tempPcmFile!!, File(cacheDir, "user_recording.wav"))
    }
    recordingThread?.start()
}

private fun stopRecording() {
    isRecording = false
    audioRecord?.apply {
        stop()
        release()
    }
    audioRecord = null
    recordingThread?.join()
    // 录制完成后可调用对比逻辑
    compareWithTtsAudio()
}

步骤2:PCM转标准WAV工具方法

手动写入符合规范的WAV文件头,确保播放器和musicg能识别:

private fun convertPcmToWav(pcmFile: File, wavFile: File) {
    val pcmDataSize = pcmFile.length().toInt()
    val wavOutputStream = FileOutputStream(wavFile)

    // 写入WAV文件头
    val totalFileSize = 36 + pcmDataSize
    val byteRate = sampleRate * 1 * 16 / 8 // 1声道、16位深

    // RIFF chunk
    wavOutputStream.write("RIFF".toByteArray())
    wavOutputStream.write(totalFileSize.toLittleEndianByteArray(4))
    wavOutputStream.write("WAVE".toByteArray())

    // fmt chunk
    wavOutputStream.write("fmt ".toByteArray())
    wavOutputStream.write(16.toLittleEndianByteArray(4)) // PCM格式大小
    wavOutputStream.write(1.toLittleEndianByteArray(2)) // 线性PCM
    wavOutputStream.write(1.toLittleEndianByteArray(2)) // 声道数
    wavOutputStream.write(sampleRate.toLittleEndianByteArray(4))
    wavOutputStream.write(byteRate.toLittleEndianByteArray(4))
    wavOutputStream.write(2.toLittleEndianByteArray(2)) // 块对齐(1声道*16位/8=2)
    wavOutputStream.write(16.toLittleEndianByteArray(2)) // 位深

    // data chunk
    wavOutputStream.write("data".toByteArray())
    wavOutputStream.write(pcmDataSize.toLittleEndianByteArray(4))

    // 写入PCM原始数据
    val pcmInputStream = FileInputStream(pcmFile)
    val buffer = ByteArray(4096)
    var readCount: Int
    while (pcmInputStream.read(buffer).also { readCount = it } != -1) {
        wavOutputStream.write(buffer, 0, readCount)
    }

    pcmInputStream.close()
    wavOutputStream.close()
    // 删除临时PCM文件
    pcmFile.delete()
}

// 扩展函数:Int转小端字节数组(WAV格式要求小端存储)
private fun Int.toLittleEndianByteArray(length: Int): ByteArray {
    val result = ByteArray(length)
    for (i in 0 until length) {
        result[i] = (this shr (8 * i)).toByte()
    }
    return result
}

步骤3:替换原有SpeechRecognizer逻辑

修改toggleButton的点击事件和权限回调,改用新的录制逻辑:

override fun onCreate(savedInstanceState: Bundle?) {
    // ... 其他原有代码 ...
    toggleButton.setOnCheckedChangeListener { _, isChecked ->
        if (isChecked) {
            progressBar.visibility = View.VISIBLE
            progressBar.isIndeterminate = true
            ActivityCompat.requestPermissions(this@AudioLessonsActivity,
                arrayOf(Manifest.permission.RECORD_AUDIO),
                permission)
        } else {
            progressBar.isIndeterminate = false
            progressBar.visibility = View.GONE
            stopRecording()
        }
    }
}

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String?>,
                                        grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
        permission -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            startRecording()
        } else {
            Toast.makeText(this@AudioLessonsActivity, "Permission Denied!", Toast.LENGTH_SHORT).show()
        }
    }
}

步骤4:用musicg对比音频

确保TTS生成的WAV和用户录制的WAV参数一致(采样率、声道数、位深),然后调用musicg API计算相似度:

private fun compareWithTtsAudio() {
    val ttsWavFile = File(cacheDir, "file1.wav")
    val userWavFile = File(cacheDir, "user_recording.wav")

    if (!ttsWavFile.exists() || !userWavFile.exists()) {
        returnedText.text = "音频文件不存在"
        return
    }

    val ttsWave = Wave(ttsWavFile.absolutePath)
    val userWave = Wave(userWavFile.absolutePath)

    // 计算相似度(0-1之间),转成百分制分数
    val similarityScore = ttsWave.similarity(userWave) * 100
    returnedText.text = "跟读得分:${similarityScore.toInt()}分"
}

验证TTS生成的WAV参数

如果TTS生成的WAV采样率不是16000Hz,需要修改sampleRate变量匹配,可通过musicg的Wave类获取参数:

val ttsWave = Wave(ttsWavFile.absolutePath)
val ttsSampleRate = ttsWave.waveHeader.sampleRate
val ttsChannels = ttsWave.waveHeader.channels

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.20 05:14:59