From d0ccdf722671c9e6b421dcc654e4dc4a348f7b41 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Wed, 8 Jul 2026 11:18:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=98=E6=80=BB=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E8=A1=8C=E4=BF=AE=E6=94=B9=E8=B0=83=E8=AF=95=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/opus_test_controller.dart | 31 +++++++++++-- .../controllers/pairing_controller.dart | 17 ++++++- .../yunqiinnovation/ble_service/BleService.kt | 14 ++++-- .../ble_service/OpusAudioManager.kt | 44 ++++++++++++++++++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index 4b5e3741e..b99139427 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -164,7 +164,10 @@ class OpusTestController extends GetxController { for (var entity in files) { if (entity is File) { final path = entity.path.toLowerCase(); - if (path.endsWith('.opus') || path.endsWith('.txt')) { + // .wav:已解码 PCM(如上行「翻译-上行」音频),直接播放;.opus 需解码 + if (path.endsWith('.opus') || + path.endsWith('.txt') || + path.endsWith('.wav')) { final fileSize = await entity.length(); externalFiles.add(PlatformFile( name: entity.path.split('/').last, @@ -206,7 +209,12 @@ class OpusTestController extends GetxController { void selectExternalFile(PlatformFile file) { selectedFiles.clear(); selectedFiles.add(file); - // 「翻译-*」文件自动套用手机端下行 opus 解码参数,套上后提示里带说明;否则只提示已选择 + // .wav 是已解码 PCM(如上行「翻译-上行」音频),点播放直接播,无需 opus 解码参数 + if (file.name.toLowerCase().endsWith('.wav')) { + statusMessage.value = '已选择WAV(已解码PCM),点播放直接播: ${file.name}'; + return; + } + // 「翻译-*」opus 文件自动套用手机端下行 opus 解码参数,套上后提示里带说明;否则只提示已选择 if (!_applyAutoDecodeParams(file.name)) { statusMessage.value = '已选择文件: ${file.name}'; } @@ -261,11 +269,28 @@ class OpusTestController extends GetxController { } try { + final inPath = selectedFiles.first.path!; + + // .wav 已是解码后 PCM(如上行「翻译-上行」音频),直接播放,跳过 opus 解码 + if (inPath.toLowerCase().endsWith('.wav')) { + statusMessage.value = '正在播放WAV...'; + await player.setFilePath(inPath); + await player.play(); + isPlaying.value = true; + statusMessage.value = '正在播放WAV音频'; + player.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + isPlaying.value = false; + statusMessage.value = '播放完成'; + } + }); + return; + } + isDecoding.value = true; statusMessage.value = '正在解码...'; // 使用杰理OPUS库进行解码 - final inPath = selectedFiles.first.path!; // 创建WAV输出文件路径 final tempDir = await getTemporaryDirectory(); diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 741b4c841..b5f9e9627 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -889,7 +889,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { // 找到匹配的设备,提取授权码 if (Platform.isIOS) { // iOS设备,提取13-22字节的授权码 - code = extractAndReverseBytes(hexData, 13, 22); + code = extractBytesBigEndian(hexData, 13, 22); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 4, 5); final pidHexIos = @@ -930,7 +930,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { } } else if (Platform.isAndroid) { // Android设备,提取15-24字节的授权码 - code = extractAndReverseBytes(hexData, 15, 24); + code = extractBytesBigEndian(hexData, 15, 24); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 6, 7); final pidHexAndroid = @@ -1165,6 +1165,19 @@ class PairingController extends GetxController with WidgetsBindingObserver { .toUpperCase(); } + /// 大端模式:按原始字节顺序提取 [start, end](含 end),不反转 + String extractBytesBigEndian(String hex, int start, int end) { + List bytes = []; + for (int i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + List slice = bytes.sublist(start, end + 1); + return slice + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + int extractAndReverseBytesToInt(String hex, int start, int end) { List bytes = []; // 将十六进制字符串解析为字节列表 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 e79364bae..1de3f765e 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 @@ -1435,7 +1435,9 @@ object BleService { } /** - * 上行音频收包长度保护:opus 帧长为 40B(立体声一帧=80B=2×40B),合法包应是 40 的整数倍且非空。 + * 上行音频收包长度保护:普通流 opus 帧=40B;通话流(8kbps/60ms)立体声一帧=120B=2×60B。 + * 两条流共用此校验,故按其公约数 40 判:40 与 120 均为 40 的整数倍,合法包应是 40 的整数倍且非空。 + * 注意不要改成 % 60——那会把普通单声道流(40B)误判为坏包。 * 非 40 整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、 * 且不计入接收统计、不喂解码器;避免坏包喂进定长流式解码器把后续整段字节流搞错位、全部解错。 * @return true=长度合法可继续处理;false=异常包(已打印告警),应丢弃 @@ -2111,6 +2113,9 @@ object BleService { recordfile?.closeFile() recordfile1Left?.closeFile() recordfile1Right?.closeFile() + // 会话结束即停解码流:既停设备上行解码,也让上行「翻译-上行」PCM WAV 补齐文件头, + // opus_test 立即可直接播放听辨(否则要等 BleService 整体 cleanup 才 finalize) + stopOpusStreamDecoding() val cmdData = byteArrayOf( BleConst.CODEC_CONTROL_CLOSE.toByte(), BleConst.AUDIO_CHANNEL_STEREO.toByte() @@ -2151,7 +2156,9 @@ object BleService { startOpusEncodeStream() recordfile1Left?.creatingFiles(); recordfile1Right?.creatingFiles(); - startOpusStreamDecoding(false, 2, 16000, 80) + // 设备端通话上行 opus 参数:8kbps/60ms/8kHz 立体声。 + // packetSize=每帧字节数=码率×帧长÷8=8000×0.06÷8=60B(单声道),立体声打包 2×60=120B。 + startOpusStreamDecoding(false, 2, 16000, 240) val cmdData = byteArrayOf( BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), channelMode.toByte() @@ -2168,7 +2175,8 @@ object BleService { */ fun openCallRecordDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { Log.i(TAG, "打开通话录音解码器...") - startOpusStreamDecoding(false, 2, 16000, 80) + // 通话录音与通话翻译走同一条设备上行流:8kbps/60ms/8kHz 立体声,packetSize=2×60=120B。 + startOpusStreamDecoding(false, 2, 16000, 240) val cmdData = byteArrayOf( BleConst.CODEC_CONTROL_CALL_RECORD_PLAY.toByte(), channelMode.toByte() 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 2ab962568..00fc65b51 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 @@ -121,6 +121,8 @@ class OpusAudioManager { private var appContext: Context? = null private var leftPcmWavWriter: WavFileWriter? = null private var rightPcmWavWriter: WavFileWriter? = null + // 上行(设备→手机)opus 解码后 PCM 的 WAV 调试文件:命名「翻译-上行」,opus_test 可直接播放听辨 + private var decodePcmWavWriter: WavFileWriter? = null private val mainHandler = Handler(Looper.getMainLooper()) @@ -280,11 +282,16 @@ class OpusAudioManager { } Log.d(TAG, "准备开始Opus数据流解码, 参数: hasHeader=$hasHeader, channel=$channel, sampleRate=$sampleRate, packetSize=$packetSize") - + + // 落地上行解码后 PCM 为 WAV,供 opus_test 直接播放听辨(参数与解码配置一致) + openDecodePcmWavWriter(channel, sampleRate) + try { decodeOpusManager?.startDecodeStream(decodeOption, object : OnDecodeStreamCallback { override fun onDecodeStream(data: ByteArray?) { if (data != null) { + // 解码后 PCM 落地一份(每帧异步入队写盘,不阻塞解码线程) + decodePcmWavWriter?.writePcmData(data) val channelCount = decodeOption?.getChannel() ?: 1 if (channelCount >= 1) { callback?.onAudioDataDecoded(data, channelCount) @@ -314,11 +321,13 @@ class OpusAudioManager { return true } catch (e: Exception) { Log.e(TAG, "启动Opus解码流失败: ${e.message}", e) + // 启动失败:关闭刚创建的上行 PCM WAV,避免留下空文件 + closeDecodePcmWavWriter() callback?.onError("decode", -1, e.message) return false } } - + /** * 停止Opus数据流解码 * @return 是否成功停止解码 @@ -327,6 +336,9 @@ class OpusAudioManager { if (decodeOpusManager?.isDecodeStream == true) { decodeOpusManager?.stopDecodeStream() + // 关闭上行解码 PCM 的 WAV 落地文件(补齐 WAV 头、无数据则删除) + closeDecodePcmWavWriter() + // 清理音频数据缓存 synchronized(audioDataBuffer) { audioDataBuffer.clear() @@ -337,6 +349,8 @@ class OpusAudioManager { callback?.onDecodeStreamStateChanged(false) return true } + // 即便未在解码(如启动失败后重复停止),也兜底关闭可能残留的写入器 + closeDecodePcmWavWriter() Log.d(TAG, "Opus数据流未在解码或OpusManager未初始化") return false } @@ -607,6 +621,32 @@ class OpusAudioManager { rightPcmWavWriter = null } + /** + * 打开上行(设备→手机)解码后 PCM 的 WAV 落地文件。 + * 命名「翻译-上行」前缀,写到 getExternalFilesDir,opus_test 扫描到 .wav 直接播放听辨; + * 采样率/声道与解码配置一致(通话翻译为 16k 立体声),16bit PCM。 + */ + private fun openDecodePcmWavWriter(channel: Int, sampleRate: Int) { + val ctx = appContext ?: return + // 复用前先关掉可能残留的旧写入器 + closeDecodePcmWavWriter() + try { + // createWavFile 内部会追加 _时间戳,这里只给前缀,最终名如「翻译-上行_20260707_153000.wav」 + decodePcmWavWriter = WavFileWriter(ctx).also { + val ok = it.createWavFile("翻译-上行", sampleRate, if (channel >= 1) channel else 1, 16) + if (!ok) decodePcmWavWriter = null + } + } catch (e: Exception) { + Log.e(TAG, "创建上行解码 PCM WAV 调试文件失败: ${e.message}", e) + decodePcmWavWriter = null + } + } + + private fun closeDecodePcmWavWriter() { + try { decodePcmWavWriter?.closeWavFile() } catch (e: Exception) { Log.e(TAG, "关闭上行解码 PCM WAV 失败", e) } + decodePcmWavWriter = null + } + /** * 写入外部音频数据到Opus编码流 * 该函数用于将外部音频数据(如麦克风录音)写入到Opus编码器中