diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index fecb459ff..ec9c07e46 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -85,6 +85,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, astStereoRecorder.enqueueRightChannel(data) } if (broadcastPeerTranslate) callBPcmPlayer.feed(data) + // 通话翻译:将对端翻译 PCM 也下发到耳机右声道 + // 仅当 BleService 中的双声道编码已启动时 writeRightPcm 才真正落到编码器,否则会被静默忽略 + bleWriteScope.launch { + bleRightMutex.withLock { + BleService.writeExternalRightAudioData(data) + } + } } // 翻译(Volcano)相关 @@ -1338,9 +1345,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "A", "$lang0->$lang1", { sendAstEvent(it) }, { data -> + // 本端翻译音频走左声道 bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) + bleLeftMutex.withLock { + BleService.writeExternalLeftAudioData(data) } } } @@ -1407,9 +1415,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "A", "$lang0->$lang1", { sendAstEvent(it) }, { data -> + // 本端翻译音频走左声道 bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) + bleLeftMutex.withLock { + BleService.writeExternalLeftAudioData(data) } } } @@ -1458,9 +1467,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "A", "$translationLang0->$translationLang1", translationLang1, { sendAstEvent(it) }, { data -> + // 本端翻译音频走左声道 bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) + bleLeftMutex.withLock { + BleService.writeExternalLeftAudioData(data) } } } @@ -1517,9 +1527,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "A", "$translationLang0->$translationLang1", translationLang1, { sendAstEvent(it) }, { data -> + // 本端翻译音频走左声道 bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) + bleLeftMutex.withLock { + BleService.writeExternalLeftAudioData(data) } } } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 0e19216c8..ada3673fe 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -236,8 +236,8 @@ object BleService { recordfile1 = RecordingFile(this.context) recordfile1!!.fileName = "手机端" - // 初始化Opus音频管理器 - if (!opusAudioManager.initialize()) { + // 初始化Opus音频管理器(传入 context 以支持双声道编码时分别写 PCM WAV 调试文件) + if (!opusAudioManager.initialize(this.context)) { Log.e(TAG, "Opus音频管理器初始化失败") // 根据需要决定是否因为Opus初始化失败而返回false } else { @@ -435,15 +435,36 @@ object BleService { } /** - * 写入外部音频数据到Opus编码流 - * 该函数用于将外部音频数据(如麦克风录音)写入到Opus编码器中 - * + * 写入外部音频数据到Opus编码流(单通道,老接口,仅用于非通话翻译场景) + * * @param data 音频数据字节数组,通常为PCM格式 */ fun writeExternalAudioData(data: ByteArray) { opusAudioManager.writeExternalAudioData(data) } + /** + * 写入外部左声道 PCM 音频数据(本端翻译音频) + * 通话翻译模式下,左声道走独立 Opus 编码器,累满 5 帧后打包下发到耳机 + * + * @param data 音频数据字节数组,PCM格式(16kHz / 16bit / 单声道) + */ + fun writeExternalLeftAudioData(data: ByteArray) { + if (data.isEmpty()) return + opusAudioManager.writeLeftPcm(data) + } + + /** + * 写入外部右声道 PCM 音频数据(对端翻译音频) + * 通话翻译模式下,右声道走独立 Opus 编码器,累满 5 帧后打包下发到耳机 + * + * @param data 音频数据字节数组,PCM格式(16kHz / 16bit / 单声道) + */ + fun writeExternalRightAudioData(data: ByteArray) { + if (data.isEmpty()) return + opusAudioManager.writeRightPcm(data) + } + // ====================================================================================================== // 扫描功能 // ====================================================================================================== @@ -1267,12 +1288,13 @@ object BleService { // ====================================================================================================== /** - * 开始Opus数据流编码 + * 开始Opus数据流编码(通话翻译模式:左右声道独立编码) + * 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B * @return 是否成功开始编码 */ private fun startOpusEncodeStream(): Boolean { - startAudioSendThread() - return opusAudioManager.startEncodeStream() + startAudioSendThread() + return opusAudioManager.startDualEncodeStream(false, 16000, 40) } /** @@ -1281,37 +1303,22 @@ object BleService { */ private fun stopOpusEncodeStream(): Boolean { stopAudioSendThread() - return opusAudioManager.stopEncodeStream() + // 停止双声道独立编码;如果有单通道遗留也一并停止 + val dualStopped = opusAudioManager.stopDualEncodeStream() + val singleStopped = opusAudioManager.stopEncodeStream() + return dualStopped || singleStopped } /** - * 将音频数据添加到发送队列 - * 只有当缓冲区达到80字节时才发送数据 - * @param data 音频数据字节数组 + * 将一包已成型的音频合帧加入下行发送队列 + * 包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] + * 双声道独立编码模式下,每次回调送来的就是一整包,直接入队,不再二次切片 + * @param data 待发送的合帧包 */ private fun addAudioDataToSendQueue(data: ByteArray) { - try { - // 将新数据添加到缓冲区 - audioBuffer.addAll(data.toList()) - - // 当缓冲区达到120字节时,发送数据 - while (audioBuffer.size >= AUDIO_CHUNK_SIZE) { - // 取出120字节数据 - val chunk = ByteArray(AUDIO_CHUNK_SIZE) - for (i in 0 until AUDIO_CHUNK_SIZE) { - chunk[i] = audioBuffer.removeAt(0) - } - - // 将数据加入发送队列 - if (!audioSendQueue.offer(chunk)) { - Log.w(TAG, "音频发送队列已满,丢弃数据块") - break - } - } - - // Log.d(TAG, "音频数据已加入缓冲区,当前缓冲区大小: ${audioBuffer.size} 字节") - } catch (e: Exception) { - Log.e(TAG, "处理音频数据异常: ${e.message}", e) + if (data.isEmpty()) return + if (!audioSendQueue.offer(data)) { + Log.w(TAG, "音频发送队列已满,丢弃当前包 size=${data.size}") } } @@ -1335,8 +1342,25 @@ object BleService { val audioChunk = audioSendQueue.poll(100, TimeUnit.MILLISECONDS) if (audioChunk != null) { - // 发送音频数据块并等待回调 - recordfile1?.saveAudioDataToWav(audioChunk) + // 合包布局:[4B 序号(大端)] + [1B 声道(0=左/1=右)] + [N × opus] + // 调试录音文件只保留拼接后的纯 opus 数据,剥离 5 字节包头再写入 + if (audioChunk.size > 5) { + val payload = audioChunk.copyOfRange(5, audioChunk.size) + recordfile1?.saveAudioDataToWav(payload) + } + // 解析包头供发送节拍日志使用 + if (audioChunk.size >= 5) { + val seq = ((audioChunk[0].toInt() and 0xFF) shl 24) or + ((audioChunk[1].toInt() and 0xFF) shl 16) or + ((audioChunk[2].toInt() and 0xFF) shl 8) or + (audioChunk[3].toInt() and 0xFF) + val chTag = when (audioChunk[4]) { + OpusAudioManager.CHANNEL_LEFT -> "L" + OpusAudioManager.CHANNEL_RIGHT -> "R" + else -> "?(${audioChunk[4]})" + } + Log.i(TAG, "音频下行发送 ch=$chTag seq=$seq size=${audioChunk.size}B queue=${audioSendQueue.size}") + } sendAudioChunkWithCallback(audioChunk) Thread.sleep(AUDIO_SEND_INTERVAL) } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt index c9bf22937..b325c7af5 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt @@ -1,5 +1,6 @@ package com.yunqiinnovation.ble_service +import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log @@ -8,6 +9,9 @@ import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback import com.jieli.jl_audio_decode.opus.OpusManager import com.jieli.jl_audio_decode.exceptions.OpusException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicBoolean @@ -25,14 +29,23 @@ class OpusAudioManager { companion object { private const val TAG = "OpusAudioManager" - + // 音频数据分块发送的常量 private const val AUDIO_CHUNK_SIZE = 120 // 每次发送120字节 private const val AUDIO_SEND_INTERVAL = 60L // 发送间隔60ms - + // 重发机制相关常量 private const val MAX_RETRY_COUNT = 1 // 最大重试次数 private const val RETRY_DELAY = 30L // 重试延迟时间(毫秒) + + // 双声道下行包声道标识:0=左(本端翻译) / 1=右(对端翻译) + const val CHANNEL_LEFT: Byte = 0 + const val CHANNEL_RIGHT: Byte = 1 + + // 下行合包:左右各自累积 BUNDLE_FRAME_COUNT 帧 opus 合并为 1 包后才下发,降低 BLE 发送频率 + private const val BUNDLE_FRAME_COUNT = 5 + // 合包头部:bytes 0-3 序号(大端 uint32),byte 4 声道标识(0=左/1=右) + private const val BUNDLE_HEADER_SIZE = 5 } // ====================================================================================================== @@ -89,6 +102,24 @@ class OpusAudioManager { //编码对象 private var encodeOpusManager: OpusManager? = null private var encodeOption: OpusOption? = null + + // 双声道独立编码对象(左右各自独立,互不影响预测状态) + private var leftEncodeManager: OpusManager? = null + private var rightEncodeManager: OpusManager? = null + private var isDualEncoding = false + + // 左右各自的 opus 帧缓存与发送序号;累满 BUNDLE_FRAME_COUNT 帧后打包下发 + private val bundleLock = Any() + private val leftBundleFrames = ArrayList(BUNDLE_FRAME_COUNT) + private val rightBundleFrames = ArrayList(BUNDLE_FRAME_COUNT) + private var leftBundleSeq = 0 + private var rightBundleSeq = 0 + + // 上下文:用于双声道编码模式下,分别为左右声道写入 mono PCM WAV 调试文件 + private var appContext: Context? = null + private var leftPcmWavWriter: WavFileWriter? = null + private var rightPcmWavWriter: WavFileWriter? = null + private val mainHandler = Handler(Looper.getMainLooper()) // 解码音频数据队列和处理线程 @@ -119,22 +150,29 @@ class OpusAudioManager { /** * 初始化Opus音频管理器 + * @param context 应用上下文,用于双声道编码模式下的 PCM WAV 调试文件写入; + * 不传则跳过 PCM WAV 调试文件落盘,其他功能不受影响 * @return 是否初始化成功 */ - fun initialize(): Boolean { + fun initialize(context: Context? = null): Boolean { if (isInitialized) return true - + try { + this.appContext = context?.applicationContext // 初始化OpusManager和OpusOption decodeOpusManager = OpusManager() decodeOption = OpusOption() - + encodeOpusManager = OpusManager() encodeOption = OpusOption() - + + // 双声道独立编码器:左右各自维持独立的 OpusManager + leftEncodeManager = OpusManager() + rightEncodeManager = OpusManager() + // 启动队列处理 startAudioQueueProcessing() - + isInitialized = true Log.d(TAG, "OpusAudioManager初始化成功") return true @@ -180,12 +218,16 @@ class OpusAudioManager { audioSendQueue.clear() // 清理Opus相关资源 + stopDualEncodeStream() decodeOpusManager = null decodeOption = null encodeOpusManager = null encodeOption = null + leftEncodeManager = null + rightEncodeManager = null callback = null - + appContext = null + isInitialized = false Log.d(TAG, "OpusAudioManager资源清理完成") } @@ -384,10 +426,212 @@ class OpusAudioManager { return false } + // ====================================================================================================== + // 双声道独立编码方法(左右声道各用独立 OpusManager,互不污染预测状态) + // 输出格式:每包 = [4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [N × opus 帧] + // 左右各自累积 BUNDLE_FRAME_COUNT 帧后打包一次,通过 onAudioDataEncoded 下发 + // ====================================================================================================== + + /** + * 启动双声道独立编码流 + * 左右各创建独立 OpusManager;左右各自累积 5 帧 opus 后合包, + * 通过 onAudioDataEncoded 输出一包(含 5 字节包头 + 5 帧 opus),由下游队列按节拍发送 + */ + fun startDualEncodeStream( + hasHeader: Boolean = false, + sampleRate: Int = 16000, + packetSize: Int = 40 + ): Boolean { + if (!isInitialized) { + Log.e(TAG, "OpusManager未初始化,无法启动双声道编码") + return false + } + if (isDualEncoding) { + Log.d(TAG, "双声道编码流已在运行") + return true + } + + synchronized(bundleLock) { + leftBundleFrames.clear() + rightBundleFrames.clear() + leftBundleSeq = 0 + rightBundleSeq = 0 + } + + openPcmWavWriters(sampleRate) + + fun makeOption() = OpusOption().apply { + setHasHead(hasHeader) + setChannel(1) + setSampleRate(sampleRate) + setPacketSize(packetSize) + } + + return try { + leftEncodeManager?.startEncodeStream(makeOption(), object : OnEncodeStreamCallback { + override fun onEncodeStream(data: ByteArray?) { + if (data != null) emitChannelFrame(CHANNEL_LEFT, data) + } + override fun onStart() { Log.i(TAG, "左声道独立编码已开始") } + override fun onComplete(outPath: String?) {} + override fun onError(code: Int, message: String?) { + Log.e(TAG, "左声道编码错误: [$code] $message") + callback?.onError("encode", code, message) + } + }) + rightEncodeManager?.startEncodeStream(makeOption(), object : OnEncodeStreamCallback { + override fun onEncodeStream(data: ByteArray?) { + if (data != null) emitChannelFrame(CHANNEL_RIGHT, data) + } + override fun onStart() { Log.i(TAG, "右声道独立编码已开始") } + override fun onComplete(outPath: String?) {} + override fun onError(code: Int, message: String?) { + Log.e(TAG, "右声道编码错误: [$code] $message") + callback?.onError("encode", code, message) + } + }) + isDualEncoding = true + callback?.onEncodeStreamStateChanged(true) + + Log.i(TAG, "双声道独立编码流已启动 sampleRate=$sampleRate packetSize=$packetSize") + true + } catch (e: Exception) { + Log.e(TAG, "启动双声道编码流失败: ${e.message}", e) + callback?.onError("encode", -1, e.message) + false + } + } + + /** + * 停止双声道独立编码流 + */ + fun stopDualEncodeStream(): Boolean { + if (!isDualEncoding) return false + isDualEncoding = false + try { leftEncodeManager?.stopEncodeStream() } catch (e: Exception) { Log.e(TAG, "停止左声道编码异常", e) } + try { rightEncodeManager?.stopEncodeStream() } catch (e: Exception) { Log.e(TAG, "停止右声道编码异常", e) } + synchronized(bundleLock) { + leftBundleFrames.clear() + rightBundleFrames.clear() + } + closePcmWavWriters() + callback?.onEncodeStreamStateChanged(false) + Log.i(TAG, "双声道独立编码流已停止") + return true + } + + /** + * 是否正在进行双声道独立编码 + */ + fun isDualEncoding(): Boolean = isDualEncoding + + /** + * 写入左声道 mono PCM(本端翻译音频) + * 累满 BUNDLE_FRAME_COUNT 帧 opus 后通过 onAudioDataEncoded 下发:[4B 序号] + [1B 声道=0] + [N × opus] + */ + fun writeLeftPcm(pcm: ByteArray) { + if (!isDualEncoding) { + return + } + if (pcm.isEmpty()) return + leftPcmWavWriter?.writePcmData(pcm) + leftEncodeManager?.writeEncodeStream(pcm) + } + + /** + * 写入右声道 mono PCM(对端翻译音频) + * 累满 BUNDLE_FRAME_COUNT 帧 opus 后通过 onAudioDataEncoded 下发:[4B 序号] + [1B 声道=1] + [N × opus] + */ + fun writeRightPcm(pcm: ByteArray) { + if (!isDualEncoding) { + return + } + if (pcm.isEmpty()) return + rightPcmWavWriter?.writePcmData(pcm) + rightEncodeManager?.writeEncodeStream(pcm) + } + + /** + * 单帧入合包缓冲;左右各自累满 BUNDLE_FRAME_COUNT 帧后整包通过回调下发 + * 输出包布局:[seq(4B 大端 uint32)] + [channel(1B)] + [N 帧 opus 顺次拼接] + */ + private fun emitChannelFrame(channelPrefix: Byte, opusFrame: ByteArray) { + val packet: ByteArray? = synchronized(bundleLock) { + when (channelPrefix) { + CHANNEL_LEFT -> { + leftBundleFrames.add(opusFrame) + if (leftBundleFrames.size >= BUNDLE_FRAME_COUNT) { + val p = buildBundlePacket(leftBundleSeq, CHANNEL_LEFT, leftBundleFrames) + leftBundleSeq++ + leftBundleFrames.clear() + p + } else null + } + CHANNEL_RIGHT -> { + rightBundleFrames.add(opusFrame) + if (rightBundleFrames.size >= BUNDLE_FRAME_COUNT) { + val p = buildBundlePacket(rightBundleSeq, CHANNEL_RIGHT, rightBundleFrames) + rightBundleSeq++ + rightBundleFrames.clear() + p + } else null + } + else -> { + Log.w(TAG, "未知声道前缀: $channelPrefix") + null + } + } + } + if (packet != null) callback?.onAudioDataEncoded(packet) + } + + private fun buildBundlePacket(seq: Int, channel: Byte, frames: List): ByteArray { + var payloadSize = 0 + for (f in frames) payloadSize += f.size + val packet = ByteArray(BUNDLE_HEADER_SIZE + payloadSize) + packet[0] = (seq ushr 24 and 0xFF).toByte() + packet[1] = (seq ushr 16 and 0xFF).toByte() + packet[2] = (seq ushr 8 and 0xFF).toByte() + packet[3] = (seq and 0xFF).toByte() + packet[4] = channel + var offset = BUNDLE_HEADER_SIZE + for (f in frames) { + System.arraycopy(f, 0, packet, offset, f.size) + offset += f.size + } + return packet + } + + private fun openPcmWavWriters(sampleRate: Int) { + val ctx = appContext ?: return + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + leftPcmWavWriter = WavFileWriter(ctx).also { + val ok = it.createWavFile("pcm_left_$ts", sampleRate, 1, 16) + if (!ok) leftPcmWavWriter = null + } + rightPcmWavWriter = WavFileWriter(ctx).also { + val ok = it.createWavFile("pcm_right_$ts", sampleRate, 1, 16) + if (!ok) rightPcmWavWriter = null + } + } catch (e: Exception) { + Log.e(TAG, "创建 PCM WAV 调试文件失败: ${e.message}", e) + leftPcmWavWriter = null + rightPcmWavWriter = null + } + } + + private fun closePcmWavWriters() { + try { leftPcmWavWriter?.closeWavFile() } catch (e: Exception) { Log.e(TAG, "关闭左声道 PCM WAV 失败", e) } + try { rightPcmWavWriter?.closeWavFile() } catch (e: Exception) { Log.e(TAG, "关闭右声道 PCM WAV 失败", e) } + leftPcmWavWriter = null + rightPcmWavWriter = null + } + /** * 写入外部音频数据到Opus编码流 * 该函数用于将外部音频数据(如麦克风录音)写入到Opus编码器中 - * + * * @param data 音频数据字节数组,通常为PCM格式 */ fun writeExternalAudioData(data: ByteArray) { diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/WavFileWriter.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/WavFileWriter.kt new file mode 100644 index 000000000..aae324fea --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/WavFileWriter.kt @@ -0,0 +1,243 @@ +package com.yunqiinnovation.ble_service + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * WAV文件写入工具类 + * 用于将PCM音频数据保存为标准WAV格式文件 + */ +class WavFileWriter(private val context: Context) { + + companion object { + private const val TAG = "WavFileWriter" + private const val WAV_HEADER_SIZE = 44 + } + + private var currentWavFile: File? = null + private var fos: FileOutputStream? = null + + // 音频参数 + private var sampleRate: Int = 16000 + private var channels: Int = 1 + private var bitsPerSample: Int = 16 + + // 异步写入相关 + private val writeQueue = LinkedBlockingQueue() + private val isWriting = AtomicBoolean(false) + private var writeThread: Thread? = null + + // 数据统计 + private var totalDataSize = 0 + + /** + * 创建新的WAV文件 + * @param fileName 文件名前缀 + * @param sampleRate 采样率,默认16000Hz + * @param channels 声道数,默认1(单声道) + * @param bitsPerSample 位深度,默认16位 + */ + fun createWavFile( + fileName: String, + sampleRate: Int = 16000, + channels: Int = 1, + bitsPerSample: Int = 16 + ): Boolean { + try { + if (fos != null || currentWavFile != null) { + closeWavFile() + } + + this.sampleRate = sampleRate + this.channels = channels + this.bitsPerSample = bitsPerSample + + // 创建文件名 + val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + val timestamp = dateFormat.format(Date()) + val filePath = + File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.wav").absolutePath + + currentWavFile = File(filePath) + currentWavFile?.createNewFile() + + fos = FileOutputStream(currentWavFile, false) + + // 写入WAV文件头(先写入占位符,稍后更新) + writeWavHeader(0) + + // 启动异步写入线程 + startWriteThread() + totalDataSize = 0 + writeQueue.clear() + Log.d(TAG, "创建WAV文件: ${currentWavFile?.absolutePath}") + Log.d( + TAG, + "音频参数 - 采样率: ${sampleRate}Hz, 声道: ${channels}, 位深: ${bitsPerSample}bit" + ) + + return true + } catch (e: Exception) { + Log.e(TAG, "创建WAV文件失败: ${e.message}", e) + return false + } + } + + /** + * 写入PCM音频数据 + * @param pcmData PCM音频数据字节数组 + */ + fun writePcmData(pcmData: ByteArray) { + if (fos == null || currentWavFile == null) { + Log.w(TAG, "WAV文件未创建,无法写入数据") + return + } + if (!isWriting.get()) { + Log.d(TAG, "写入线程停止运行,停止写入数据") + return + } + try { + // 将数据放入队列,由写入线程异步处理 + writeQueue.offer(pcmData.clone()) + totalDataSize += pcmData.size + + // Log.d(TAG, "添加PCM数据到写入队列: ${pcmData.size} 字节,总计: ${totalDataSize} 字节") + } catch (e: Exception) { + Log.e(TAG, "写入PCM数据失败: ${e.message}", e) + } + } + + /** + * 关闭WAV文件并更新文件头 + */ + fun closeWavFile() { + try { + if (fos == null || currentWavFile == null) { + return + } + + // 停止写入线程 + isWriting.set(false) + writeThread?.join(1000) // 等待写入线程结束 + val finalDataSize = totalDataSize + fos?.close() + fos = null + writeQueue.clear() + // 更新WAV文件头 + currentWavFile?.let { file -> + if (file.length() <= WAV_HEADER_SIZE) { + // 如果文件只有头部没有数据,删除文件 + file.delete() + Log.d(TAG, "WAV文件无音频数据,已删除: ${file.absolutePath}") + } else { + // 更新文件头中的数据大小信息 + updateWavHeader(file, finalDataSize) + Log.d(TAG, "WAV文件保存完成: ${file.absolutePath}") + Log.d(TAG, "文件大小: ${file.length()} 字节,音频数据: ${finalDataSize} 字节") + } + } + + } catch (e: Exception) { + Log.e(TAG, "关闭WAV文件失败: ${e.message}", e) + } finally { + currentWavFile = null + totalDataSize = 0 + } + } + + /** + * 启动异步写入线程 + */ + private fun startWriteThread() { + if (isWriting.get()) return + + isWriting.set(true) + writeThread = Thread { + try { + while (isWriting.get() || writeQueue.isNotEmpty()) { + val data = writeQueue.poll() ?: continue + fos?.write(data) + fos?.flush() + } + } catch (e: Exception) { + Log.e(TAG, "异步写入音频数据失败: ${e.message}", e) + } + } + writeThread?.start() + } + + /** + * 写入WAV文件头 + * @param dataSize 音频数据大小 + */ + private fun writeWavHeader(dataSize: Int) { + val header = ByteBuffer.allocate(WAV_HEADER_SIZE) + header.order(ByteOrder.LITTLE_ENDIAN) + + // RIFF头 + header.put("RIFF".toByteArray()) + header.putInt(36 + dataSize) // 文件大小 - 8 + header.put("WAVE".toByteArray()) + + // fmt子块 + header.put("fmt ".toByteArray()) + header.putInt(16) // fmt子块大小 + header.putShort(1) // 音频格式(PCM) + header.putShort(channels.toShort()) // 声道数 + header.putInt(sampleRate) // 采样率 + header.putInt(sampleRate * channels * bitsPerSample / 8) // 字节率 + header.putShort((channels * bitsPerSample / 8).toShort()) // 块对齐 + header.putShort(bitsPerSample.toShort()) // 位深度 + + // data子块 + header.put("data".toByteArray()) + header.putInt(dataSize) // 音频数据大小 + + fos?.write(header.array()) + fos?.flush() + } + + /** + * 更新WAV文件头中的数据大小信息 + * @param file WAV文件 + * @param dataSize 实际音频数据大小 + */ + private fun updateWavHeader(file: File, dataSize: Int) { + try { + RandomAccessFile(file, "rw").use { raf -> + // 更新文件大小(位置4) + raf.seek(4) + raf.write(intToLittleEndianBytes(36 + dataSize)) + + // 更新数据大小(位置40) + raf.seek(40) + raf.write(intToLittleEndianBytes(dataSize)) + } + } catch (e: IOException) { + Log.e(TAG, "更新WAV文件头失败: ${e.message}", e) + } + } + + /** + * 将整数转换为小端字节序字节数组 + */ + private fun intToLittleEndianBytes(value: Int): ByteArray { + return byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte(), + ((value shr 16) and 0xFF).toByte(), + ((value shr 24) and 0xFF).toByte() + ) + } +}