From cb76b101e89a48f532d05653c45f6a1895077155 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 3 Jul 2026 22:20:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9A=E4=B8=80=E4=B8=8B=20=E6=A0=87?= =?UTF-8?q?=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/通话翻译-流程与控流协议.md | 347 +++++++++++++++ lib/core/utils/logger.dart | 30 +- lib/data/services/ble_manager.dart | 21 + .../speech_impl/azure_ast_service.dart | 4 +- lib/main.dart | 12 + .../call_translation_debug_controller.dart | 104 ++++- .../views/call_translation_debug_view.dart | 100 +++++ .../controllers/log_export_controller.dart | 20 + .../log_export/views/log_export_view.dart | 6 + .../controllers/opus_test_controller.dart | 97 ++++- .../opus_test/views/opus_test_view.dart | 30 +- .../yunqiinnovation/agent_service/BleAgent.kt | 13 +- .../azure_speech/AstCallbacks.kt | 39 +- .../azure_speech/AzureSpeechPlugin.kt | 17 + .../yunqiinnovation/azure_speech/CallLog.kt | 72 ++++ .../ble_service/BleCommandSender.kt | 6 +- .../yunqiinnovation/ble_service/BleService.kt | 394 +++++++++++++++--- .../ble_service/BleServicePlugin.kt | 27 +- .../yunqiinnovation/ble_service/CallLog.kt | 73 ++++ .../ble_service/OpusAudioManager.kt | 14 +- .../ble_service/lib/ble_service.dart | 29 +- 21 files changed, 1345 insertions(+), 110 deletions(-) create mode 100644 docs/通话翻译-流程与控流协议.md create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallLog.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/CallLog.kt diff --git a/docs/通话翻译-流程与控流协议.md b/docs/通话翻译-流程与控流协议.md new file mode 100644 index 000000000..0698aa2fc --- /dev/null +++ b/docs/通话翻译-流程与控流协议.md @@ -0,0 +1,347 @@ +# 通话翻译:流程与控流协议 + +> 适用分支:`newdev_chengguofeng`(以当前 HEAD 实际代码为准) +> 关键词:Jieli 耳机 · BLE 双声道 · 端到端语音翻译(AST/STS) · 下行流控 +> 维护提示:本文描述的是**当前流式下行**实现(累帧合包 + 固定节拍 + F4/F5 三档调速 + 背压重试)。历史上讨论过的「整句 gate + 编码器静默确认 flush」方案**当前代码未采用**,不在本文范围。 + +--- + +## 1. 概述 + +通话翻译(Call Translation)让用户戴 Jieli 蓝牙耳机通话时,实现**双向实时同传**: + +- **本端**说话 → 翻译成对端语言 → 播给**对方** +- **对方**说话 → 翻译成本端语言 → 播给**本端** + +技术上是一条闭环链路:设备把「本端麦克风原声 + 对方原声」合成**立体声 Opus** 经 BLE 上行到手机;手机拆声道后送入**两路独立的端到端语音翻译服务**(A 路 / B 路);两路译音再各自 Opus 编码,经 BLE 下行回设备播放。 + +核心难点在**下行控流**:BLE 是半双工、带宽有限,设备端解码缓存易溢出丢包,因此下行侧做了合包、固定节拍、左右声道公平调度、设备反馈三档调速、写背压重试等多层流控。 + +--- + +## 2. 术语与声道语义(最易混淆,先钉死) + +| 术语 | 含义 | +|---|---| +| A 路 | 本端语言 → 对端语言 的端到端翻译 session(识别本端说话) | +| B 路 | 对端语言 → 本端语言 的端到端翻译 session(识别对方说话) | +| 上行 | 设备 → 手机(原声) | +| 下行 | 手机 → 设备(译音) | +| AST / STS | 端到端语音翻译(ASR + 翻译 + TTS 一体化服务) | + +**声道语义在三个场景各不相同,务必区分:** + +| 场景 | 左声道 | 右声道 | +|---|---|---| +| **上行拆声道**(`onAudioDataReceived`) | 己方麦克风原声 → 喂 **A 路** | 对方原声 → 喂 **B 路** | +| **下行译音**(写回设备) | **A 路输出**(本端译音,给对方听) | **B 路输出**(对端译音,给本端听) | +| **立体声录音**(AstStereoRecorder) | 己方麦克风原声 | B 路译音 | + +> 说明:App 只负责定义「左=本端译音、右=对端译音」,最终左/右声道播到哪只耳、如何混音,由**设备固件**决定。 + +--- + +## 3. 架构分层与端到端数据流 + +``` +┌───────────── 设备(Jieli耳机) ──────── BLE ──────── 手机(App) ─────────────────────┐ +│ │ +│ 上行 [己方mic + 对方声] 立体声Opus │ +│ ──notify(CALL_RECEIVE_AUDIO_CHAR 0xABC2-0001)──▶ │ +│ OpusAudioManager 解码(2ch/16k/帧80) │ +│ │ │ +│ ▼ AzureSpeechPlugin.onAudioDataReceived │ +│ 拆交错立体声 → left=己方 / right=对方 │ +│ 静音门控(50帧保活) + 噪声门 │ +│ left→pushAstAudioToA / right→pushAstAudioToB │ +│ │ │ +│ ▼ 端到端翻译层 (azure/volcano/alibaba/iflytek)│ +│ A: 己方语→对端语 ─TTS─▶ 左声道译音 PCM │ +│ B: 对端语→己方语 ─TTS─▶ 右声道译音 PCM │ +│ │ │ +│ 下行 独立Opus编码 ◀── 左: writeExternalLeftAudioData(A路) │ +│ (CALL_WRITE_AUDIO_CHAR 0xABC1-0001) 右: writeExternalRightAudioData(B路) │ +│ 累bundleFrameCount帧合包 → 40ms节拍发送线程 → F4/F5三档调速 → 背压重试 │ +│ ──write(NO_RESPONSE)──▶ 设备解码播放 │ +│ │ +│ 指令旁路 0xAA请求 / 0xBB响应 / 0xCC上报 (NOTIFY_CHAR 0xABC2-0000, CRC8) │ +└───────────────────────────────────────────────────────────────────────────────────┘ +``` + +**代码分层:** + +| 层 | 位置 | 职责 | +|---|---|---| +| Dart 会话编排 | `lib/modules/translation/controllers/translation_controller.dart` | call 模式主控:开关编解码、选 provider、处理 AST 事件、监听设备 0x16/0x17 | +| Dart 调试页 | `lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart` | 手动开关 + 下发调速参数 | +| Dart BLE 封装 | `lib/data/services/ble_manager.dart` | 解析 0x16/0x17 → 广播 `onCallTranslationRequest` + 自动导航 | +| Dart AST 抽象 | `lib/data/services/ast_service.dart` · `speech_impl/azure_ast_service.dart` | MethodChannel `azure_speech/ast` + EventChannel `ast_events` | +| Native 翻译运行时 | `local_plugins/azure_speech/.../AzureSpeechPlugin.kt` · `AstCallbacks.kt` | 上行拆声道推 A/B;下行 A→左 / B→右 PCM;四家 AST 回调 | +| Native BLE 服务 | `local_plugins/ble_service/.../BleService.kt` · `OpusAudioManager.kt` · `BleCommandSender.kt` | GATT 连接、Opus 编解码、**下行发送线程与流控**、指令协议 | + +> 注:`AzureSpeechPlugin` 与 `BleService`(Kotlin `object` 单例)在**同一进程**,下行译音 PCM 是 native 侧**直接调用** `BleService.writeExternalLeftAudioData/RightAudioData`,**不经过 Dart↔MethodChannel**。 + +--- + +## 4. BLE 服务与特征 + +| 服务 | UUID | 特征 | UUID | 用途 | +|---|---|---|---|---| +| 主服务 | `0000abc0-0000-…` | WRITE | `0000abc1-0000-…` | 指令下发(0xAA) | +| | | NOTIFY | `0000abc2-0000-…` | 指令响应/上报(0xBB/0xCC) | +| 通话音频服务 | `0000ABC0-0001-…` | CALL_WRITE | `0000ABC1-0001-…` | **下行译音**写入(NO_RESPONSE) | +| | | CALL_RECEIVE | `0000ABC2-0001-…` | **上行原声** notify | +| 音频服务 | `0000ae00-…` | RECEIVE_AUDIO | `0000ae02-…` | 单声道音频 notify(非通话翻译场景) | +| 唤醒词 OTA | `0000ABC0-0002-…` | WRITE/NOTIFY | `0000ABC1/2-0002-…` | 唤醒词升级 | + +- 写类型:`CALL_WRITE` 与指令 `WRITE` 均为 `WRITE_TYPE_NO_RESPONSE`(不等对端 ACK,保吞吐)。 +- CCCD(`00002902-…`)**串行注册**:Android BLE 协议栈一次仅允许一个 GATT 操作排队,必须等上一个 `onDescriptorWrite` 回调再写下一个,否则后续调用被丢弃。 + +--- + +## 5. 会话生命周期 + +### 5.1 开启(双触发,殊途同归到 `openA2DPDecoder()`) + +``` +① App 主动 + TranslationController.startRecognition() + → _configureAudioMode() → case "call": _configureCallMode() + → bleManager.openA2DPDecoder() (重试3轮×13次×150ms 轮询 isCodecActive) + +② 设备主动 + 设备上报 0x16(CMD_CALL_TRANSLATION_ON) + → BleCommandSender.processDeviceNotification → notifyDeviceInfoReceived + → Dart BleManager._processDeviceInfoByCommandId → _handleCallTranslationRequest(true) + → _callTranslationRequestController.add(true) (广播 onCallTranslationRequest) + → 若不在翻译页: Get.toNamed(Routes.translation, {translationId:'call', autoStart:true}) + → TranslationController._setupDeviceCallTranslationListener 消费: + changeTranslationMode('call') + startRecognition() +``` + +`BleService.openA2DPDecoder()`(native)**一次做三件事**: + +```kotlin +fun openA2DPDecoder(channelMode = STEREO): Boolean { + startOpusEncodeStream() // ① 下行: 启动发送线程 + 双声道独立编码(16k/帧40) + startOpusStreamDecoding(false, 2, 16000, 80) // ② 上行: 启动解码(2声道/16k/帧80) + sendCommand(0x05, [0xA2, channelMode]) // ③ 下发指令: A2DP_PLAY +} +``` + +### 5.2 关闭 + +- 正常:`TranslationController.stopAll()`(mode==call)→ `bleManager.closeCodec()` → 指令 `0x05,[0x00,STEREO]` + 停编解码线程。 +- 出错兜底:`_terminateCallOnError()`。 +- 设备主动:上报 `0x17(CMD_CALL_TRANSLATION_OFF)`。 +- 抢占:iOS 上语音助手激活时 `agent_service` 也会 `closeCodec()`。 + +--- + +## 6. 上行链路(设备 → 翻译) + +**入口**:`AzureSpeechPlugin.onAudioDataReceived(data, channel)`(实现 `BleService.Callback`,启动时 `BleService.addCallback(this)`)。 + +设备上报的通话翻译音频解码后 `channel==2`(立体声),处理流程: + +1. **拆交错立体声**:每样本 4 字节(左右各 2 字节 PCM16 小端)→ `leftBuffer`(己方麦克风) / `rightBuffer`(对方),同时计算左右 `peak`。 +2. **上行控流 · 静音门控**: + - 某路连续 `silenceFramesToMute = 50` 帧(单帧 20ms ≈ 1s)`peak < gatePeakThreshold(500)` 视为静音 → 改推**零 PCM 保活**。 + - 目的:端到端 STS 服务(豆包/阿里)持续收到空音频会触发 `stream is done`/`audio not enough(1011)`/session timeout 断连;推零帧保持流不断。 +3. **上行控流 · 噪声门** `filterLowVolumeAudio`:低于 `lowVolumeThreshold` 的样本置 0,去底噪。 +4. **分发**:`pushAstAudioToA(leftBuffer)` / `pushAstAudioToB(rightBuffer)`,按 `currentAstProvider` 打到 `xxxAstHelperA/B.pushAudioData()`。 +5. **回声门控** `gateAgainstBPlayback`:仅当 `broadcastPeerTranslate=true`(对端译音本地扬声器播报)时生效——播报期间把推给 AST 的音频置零,避免扬声器声被麦克风拾取形成回环。 + +--- + +## 7. 端到端翻译层(AST / STS) + +- **Provider**:`azure` / `volcano`(豆包 DoubaoE2E) / `alibaba`(阿里百炼) / `iflytek`(讯飞)。每家各有 **A、B 两路独立 session**。 +- **选择**(Dart):`_initializeCallModeTranslationService` 按语言对 `findBestMatchingProvider(src,tgt)` 得 provider(默认 `azure`),组装 6 元语言参数 `[asrSrc, asrTgt, transSrc, transTgt, ttsSrc, ttsTgt]` → `_astService.initialize(provider)`。 +- **输出映射**(native,`AzureSpeechPlugin` 初始化各 provider 时给回调传 lambda): + - **A 路 TTS PCM → 左声道**:`bleWriteScope.launch { bleLeftMutex.withLock { BleService.writeExternalLeftAudioData(data) } }` + - **B 路 TTS PCM → 右声道**:统一走 `bPcmWriter`: + ```kotlin + private val bPcmWriter = AudioWriter { data -> + if (astStereoRecorder.isActive) astStereoRecorder.enqueueRightChannel(data) // 立体声录音右轨 + if (broadcastPeerTranslate) callBPcmPlayer.feed(data) // 可选本地播报 + bleWriteScope.launch { bleRightMutex.withLock { + BleService.writeExternalRightAudioData(data) }} // 下发右声道 + } + ``` +- **TTS PCM 来源**:`AstCallbacks.kt` 各家合成回调 → `audioWriter?.write(pcmData)`:Azure `onSynthesisAudioGenerated`、豆包 `onPartialAudio`(带 `[LAT-TRACE] 2.app收到译音` 延迟埋点)、阿里 `onPartialAudio`。 +- **事件回上**:native `sendAstEvent` → EventChannel → `AzureAstService._handleRecognitionEvent` → `ASTEvent` → `TranslationController._handleAstEvent`(识别文本/译文 UI 展示)。 + +--- + +## 8. 下行链路与控流(核心) + +下行是整个功能最复杂的部分。译音 PCM 从写入到发出经过:**独立编码 → 累帧合包 → 主队列 → 声道分流 → 固定节拍调度 → 背压写入**。 + +### 8.1 编码与合包(`OpusAudioManager`) + +- 左右声道各持有**独立 `OpusManager`**(`leftEncodeManager` / `rightEncodeManager`),互不污染预测状态;参数 mono / 16kHz / 帧长 40。 +- `writeLeftPcm/writeRightPcm(pcm)` → `encodeManager.writeEncodeStream(pcm)`(**异步**:PCM 入内部线程队列即返回,真正编码在编码器内部线程,回调 `onEncodeStream` 延迟触发)。 +- 每编出一帧 → `emitChannelFrame` 累积;累满 `bundleFrameCount`(默认 **4**,可调 1..20)帧后**合包**并通过 `onAudioDataEncoded` 下发。 + +**合包格式**(`buildBundlePacket`): + +``` +[ byte0..3 : 序号 seq (uint32, 小端) ][ byte4 : 声道 (0=左 / 1=右) ][ byte5.. : N 帧 opus 顺次拼接 ] +``` + +> ⚠️ 代码订正:`BleService.kt` 内 `startOpusEncodeStream` 上方注释写「4B 序号(大端)…5×opus…205B」,与实际实现不符。**实际实现为小端序号**(`buildBundlePacket` 低字节在前、`trySendChannelChunk` 也按小端解析),且默认合 **4** 帧、opus 帧不定长(非固定 205B)。以本文与代码实现为准。 + +### 8.2 发送线程与调度(`BleService.startAudioSendThread`) + +固定 **40ms 一拍**(`audioSendIntervalNormal`,睡到下一拍绝对时刻,节拍不随耗时抖动)。每拍决策一次「发不发 / 发哪路」: + +``` +每一拍(40ms): + 1) 非阻塞抽干主队列 audioSendQueue → routeToHoldBuffer 按声道分流到 leftHoldBuffer / rightHoldBuffer + 2) 本拍最多发 1 包: + leftDue = leftHoldBuffer 非空 且 (tick - lastLeftSendTick >= leftPaceTicks) + rightDue = rightHoldBuffer 非空 且 (tick - lastRightSendTick >= rightPaceTicks) + 两路都到点 → holdDrainPreferLeft 轮转二选一(公平, 防饿死) + 选中路 trySendChannelChunk() 成功才更新该路 lastSendTick + 3) 睡到下一拍 +``` + +### 8.3 六层下行控流 + +| # | 机制 | 实现 | 作用 | +|---|---|---|---| +| 1 | **合包** | `bundleFrameCount`(默认4) 帧/包 | 降低 BLE 写频率与包头开销 | +| 2 | **固定节拍** | `AudioSendThread` 40ms 绝对时刻对齐 | 平稳下发,不因编码/发送耗时抖动 | +| 3 | **声道公平** | 左右独立 holdBuffer + `paceTicks` 到点 + `holdDrainPreferLeft` 轮转 | 一拍一包、左右交替,任一路不饿死 | +| 4 | **F4/F5 反馈调速** | `onDecodeFreeReported` → `nextPaceTicks` 三档 | 按设备解码空余动态降/升速,防溢出丢包 | +| 5 | **写背压重试** | `sendAudioChunkBlocking` 同步返回值 | 拥塞(false)→放回队头下一拍重试,**绝不丢包**、声道内保序 | +| 6 | **OOM 兜底** | `holdBuffer > CHANNEL_HOLD_BUFFER_MAX(10000)` 丢最旧 | 纯防内存爆掉(正常流控不触发) | + +**第 4 层 · F4/F5 三档调速细节**(`nextPaceTicks`,带迟滞): + +设备通过 `0xCC` 主动上报左(`0xF4`)/右(`0xF5`)声道**解码缓存空余字节数**(int32 大端)。空余越小说明设备解码越来不及,越要降速。三档 = 拍数 1 / 4 / 8 = **40 / 160 / 320 ms/包**: + +``` +freeBytes ≤ 1120 (DECODE_FREE_CRIT_DOWN) ─────────────▶ 8 拍(320ms) 紧急降速 +freeBytes ≥ 2880 (DECODE_FREE_NORMAL_UP) ─────────────▶ 1 拍(40ms) 全速 +处于 8 拍档: freeBytes ≥ 1440 (CRIT_UP) 才回升 4 拍(160ms) +处于 1 拍档: freeBytes ≤ 1920 (NORMAL_DOWN) 才降到 4 拍(160ms) +其余(4 拍档)保持 +``` + +> 设计取向:**降档敏感**(及早减速防溢出)、**升档保守**(回升够多才提速),中间留迟滞带消除边界横跳。仅更新对应声道档位,由发送线程下一拍按新档位执行。F4/F5 流控**完全内聚在 `BleService`**,插件层不再有 sleep/resume 回调钩子。 + +### 8.4 关键状态复位 + +`stopAudioSendThread()` 会清空主队列与左右 holdBuffer,并复位:`paceTicks→1(全速)`、`lastSendTick→-1`、`holdDrainPreferLeft→true`、包序追踪、拥塞计数、延迟埋点计时。保证下次会话干净起步。 + +--- + +## 9. 连接层优化(为双向吞吐服务) + +半双工 BLE 上下行共享连接事件,下行写过多会挤占设备上行 notify 时隙(表现为「设备端发数据失败」)。服务发现完成后依次做: + +1. **CCCD 串行注册**:按队列逐个开启 `callAudio / audio / notify / wakeupVoices` 通知。 +2. **`CONNECTION_PRIORITY_HIGH`**:缩短连接间隔、增大双向带宽(走 HCI 连接参数更新,不占 GATT 队列;代价更耗电,断开自动复位)。 +3. **PHY 2M**:`readPhy` → 延时 `setPreferredPhy(2M)`(API 26+,失败回退 1M)。 +4. **MTU 协商**:`onMtuChanged` 更新可用块大小(-5 字节 ATT 头)。 + +--- + +## 10. 指令协议 + +### 10.1 帧格式 + +``` +APP 请求 : 0xAA [CMD] [LEN] [DATA...] [CRC8] +设备响应 : 0xBB [CMD] [LEN] [DATA...] [CRC8] +设备主动上报: 0xCC [TYPE][LEN] [DATA...] [CRC8] +``` + +- **CRC8**:`calculateCrc8Maxim` — 初值 0,多项式 `0x31`,逐字节 xor 后左移 8 次(最高位为 1 则 `^0x31`),对帧头到 DATA(不含 CRC 自身)计算。 +- 响应帧长自检:`LEN != data.size-4` 判为「无数据长度的失败响应」,回调失败并放行队列。 + +### 10.2 指令与子命令表 + +| CMD | 名称 | 说明 | +|---|---|---| +| 0x01 | GET_VERSION | 耳机版本 | +| 0x02 | GET_PRODUCT_ID | 产品 ID | +| 0x03 | GET_COLOR_ID | 颜色 ID | +| 0x04 | GET_BATTERY_INFO | 电量(左/右耳/仓,bit7=充电中) | +| **0x05** | **CONTROL_CODEC** | **编解码控制(见下表)** | +| 0x06/0x07 | VOLUME_UP/DOWN | 音量 | +| 0x08 | GET_WAKEUP_VERSION | 唤醒词版本 | +| 0x09/0x10 | START/END_SINGLE_DIALOG | AI 单次对话(非通话翻译) | +| 0x11 | WAKE_UP | 唤醒上报 | +| 0x14/0x15 | GET/SET_VOICE_WAKEUP_STATUS | 语音唤醒开关 | +| **0x16/0x17** | **CALL_TRANSLATION_ON/OFF** | **设备上报 通话翻译 开/关** | + +**0x05 CONTROL_CODEC 子命令(DATA[0]):** + +| 值 | 名称 | 含义 | +|---|---|---| +| 0x00 | CLOSE | 关闭编解码 | +| 0xA1 | DECODE_ON | 打开解码(音乐/通话远端) | +| **0xA2** | **A2DP_PLAY** | **通话翻译:mic+dac 声音 + 翻译后重新编码** | +| 0xA3 | CALL_RECORD_PLAY | 通话录音播放 | +| 0xB1 | ENCODE_ON | 打开编码 | +| **0xF4/0xF5** | **DECODE_FREE_LEFT/RIGHT** | **上报 左/右声道解码空余字节数(下行流控)** | + +DATA[1] 声道模式:`0x01`=左 / `0x02`=右 / `0x03`=立体声。 + +**F4/F5 上报帧示例**:`0xCC 0x05 [len] [0xF4|0xF5] [int32 大端 freeBytes] [crc]`,解析于 `BleCommandSender.processDeviceNotification` → `readInt32BE` → `notifyCallDecodeFreeReported(channel, freeBytes)` → `BleService.onDecodeFreeReported`。 + +### 10.3 指令 ACK 与超时(`BleCommandSender`) + +- 串行:`isWaitingReply` 为真才直接发,否则入 `commandQueue` 排队。 +- 发出后 `isWaitingReply=false` 并启动 `1s` 超时;收到 `0xBB` 响应 → `onCommandResponse` 复位并 `processNextCommand`。 +- **超时兜底**:`REPLY_TIMEOUT_MS=1000ms` 到期自动 `isWaitingReply=true` 并放行下一条,避免死锁。 + +--- + +## 11. 调参与调试 + +**运行时可调参数**(MethodChannel `setCallTranslationDebugParams`,调试页 `call_translation_debug`): + +| 参数 | 默认 | 范围 | 说明 | +|---|---|---|---| +| `sendIntervalMs` | 40 | 1..1000 | 下行发送节拍(一拍时长 ms) | +| `bundleFrameCount` | 4 | 1..20 | 每包合并的 opus 帧数 | + +**听辨调试基础设施**(长期保留,勿当调试痕迹删): + +- 下行译音 PCM 落盘:`OpusAudioManager` 用 `WavFileWriter` 写 `pcm_left_*` / `pcm_right_*`(`getExternalFilesDir`)。 +- `BleService.recordfile`(耳机端上行) / `recordfile1`(下行发送) WAV 落盘。 +- `opus_test` 模块:扫描 wav/opus、按名排序、WAV 直接播放、一键清理——用户长期排查「丢句/卡顿/一句话不完整」的核心听辨工具。 + +**延迟埋点** `[LAT-TRACE]`:点2(app 收到译音,`DoubaoAstCallback`) ↔ 点3(译音写到 BLE,`BleService.trySendChannelChunk` 右声道首包),同时钟相减即 app 内部下行耗时(间隔 >700ms 视为新一句)。 + +--- + +## 12. 关键源码索引 + +| 功能 | 文件:符号 | +|---|---| +| call 模式主控 | `translation_controller.dart` → `startRecognition` / `_configureCallMode`(≈1198) / `stopAll`(≈1966) / `_handleAstEvent` | +| 0x16/0x17 路由 | `ble_manager.dart` → `_processDeviceInfoByCommandId`(≈372) / `_handleCallTranslationRequest`(≈427) / `onCallTranslationRequest`(≈253) | +| provider 选择 | `translation_controller.dart` → `_initializeCallModeTranslationService`(≈897) | +| 上行拆声道+门控 | `AzureSpeechPlugin.kt` → `onAudioDataReceived`(1877) / `pushAstAudioToA/B`(182/205) / `gateAgainstBPlayback`(177) | +| 下行左/右写入 | `AzureSpeechPlugin.kt` → callbackA lambda(1351/1421/1473/1533) / `bPcmWriter`(81) | +| TTS PCM 来源 | `AstCallbacks.kt` → 各家 `onSynthesisAudioGenerated`/`onPartialAudio` | +| 双声道编码+合包 | `OpusAudioManager.kt` → `writeLeftPcm/writeRightPcm`(543/556) / `emitChannelFrame`(585) / `buildBundlePacket`(616) | +| 下行发送线程 | `BleService.kt` → `startAudioSendThread`(1421) / `trySendChannelChunk`(1492) / `routeToHoldBuffer`(1570) / `sendAudioChunkBlocking`(1616) | +| F4/F5 调速 | `BleService.kt` → `onDecodeFreeReported`(2204) / `nextPaceTicks`(2230) | +| 会话入口 | `BleService.kt` → `openA2DPDecoder`(1870) / `closeCodec`(1838) | +| 指令协议 | `BleCommandSender.kt` → `createCommandPacket`(714) / `processDeviceResponse`(201) / `processDeviceNotification`(492) / `calculateCrc8Maxim`(801) | +| 常量定义 | `BleConst.kt`(UUID / CMD / CODEC 子命令 / 阈值) | + +--- + +## 13. 注意事项 / 已知不一致 + +1. **合包序号字节序**:实际为**小端**;`BleService.kt` `startOpusEncodeStream` 上方注释误写「大端 / 5×opus / 205B」,属旧注释残留,以实现为准。 +2. **异步编码**:`writeEncodeStream` 非同步,句尾最后几帧可能仍在编码器内部队列——当前流式下发不做整句边界对齐,靠合包+节拍平滑输出。 +3. **iOS 对称实现**:`ble_service/ios` 与 `azure_speech/ios` 有同名对称文件;下行整句相关的高级 gate 逻辑(若未来引入)iOS 侧需单独实现。 +4. **非通话翻译指令**(单次对话 0x09/0x10、唤醒 0x11、唤醒词 OTA)与本功能共用同一 BLE 通道与指令协议,但不属于通话翻译数据流,本文不展开。 diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 05d276c72..e1ad92008 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -39,14 +39,17 @@ class Logger { static String? _logDirPath; static final DateFormat _fileDateFormat = DateFormat('yyyy-MM-dd'); + /// 日志文件名时间戳(精确到秒):每次启动/跨天各一个独立文件,方便在导出列表里区分不同运行 + static final DateFormat _fileStampFormat = DateFormat('yyyy-MM-dd_HHmmss'); + /// 单行最大字符数,超过则截断(避免日志被过长字符串拖慢) static const int _maxLineLength = 4000; /// 最大单个日志文件大小 (2MB) static const int _maxFileSize = 2 * 1024 * 1024; - /// 最多保留日志文件数量 - static const int _maxLogFiles = 7; + /// 最多保留日志文件数量(文件名精确到秒后每次启动一个文件,适当调大以免频繁启动把当天日志冲掉) + static const int _maxLogFiles = 20; /// 内存队列:写日志只入队,由定时器批量落盘 static final List _queue = []; @@ -100,8 +103,10 @@ class Logger { /// 打开/创建当前日志文件 static Future _openLogFile() async { - final today = _fileDateFormat.format(DateTime.now()); - final filePath = '$_logDirPath/app_$today.log'; + final now = DateTime.now(); + final today = _fileDateFormat.format(now); + // 文件名精确到秒:每次启动/跨天都是独立文件,导出列表里可按运行时段区分 + final filePath = '$_logDirPath/app_${_fileStampFormat.format(now)}.log'; _currentLogFile = File(filePath); await _logSink?.flush(); await _logSink?.close(); @@ -218,6 +223,23 @@ class Logger { _queue.add('$ts [$level][$tag] $msg'); } + /// 接收 native 层**批量**上报的日志(多行以 \n 拼接,每行已含 native 毫秒时间戳/级别/tag), + /// 拆开后与 Dart 日志写入同一个 app_*.log。native 侧批量上报以降低主线程 post 频率、避免卡顿。 + static void appendNative(String payload) { + if (!_fileLogEnabled) return; + for (final line in payload.split('\n')) { + if (line.isEmpty) continue; + String msg = line; + if (msg.length > _maxLineLength) { + msg = '${msg.substring(0, _maxLineLength)}… (truncated)'; + } + if (_queue.length >= _maxQueueSize) { + _queue.removeRange(0, _queue.length - _maxQueueSize + 1); + } + _queue.add(msg); + } + } + /// 获取日志目录路径 static String? get logDirPath => _logDirPath; diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 790bd82a6..8e6663766 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -384,6 +384,14 @@ class BleManager extends GetxService { _updateBatteryInfoFromData(data); break; case BleCommands.CONTROL_CODEC: + // F4/F5 解码空余上报也复用 command=0x05,但它是下行流控上报(native 已内部处理调速), + // 不是"开/关编解码"的应答。必须先排除:否则每条 F4/F5 都会被当成应答,其第二字节(空余 + // 字节数 int32 大端最高位)恒为 0 → 判"失败",既刷屏"编解码控制失败",又可能污染 + // _isCodecActive / 吞掉真正的应答(在等待开关应答的窗口内先收到 F4/F5 时)。 + final int? codecStatus = (data['codecStatus'] as num?)?.toInt(); + if (codecStatus == 0xF4 || codecStatus == 0xF5) { + break; + } final bool codecSuccess = _isCodecControlSuccess(data); if (_awaitingCodecOpen) { _isCodecActive.value = codecSuccess; @@ -1249,14 +1257,27 @@ class BleManager extends GetxService { /// 设置通话翻译调试参数(运行时生效) /// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数 + /// tierN* 为三档控流:每档 空余下限(FreeBytes) + 发送间隔(SendMs) Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, + int? tier1FreeBytes, + int? tier1SendMs, + int? tier2FreeBytes, + int? tier2SendMs, + int? tier3FreeBytes, + int? tier3SendMs, }) async { try { return await _bleService.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs, bundleFrameCount: bundleFrameCount, + tier1FreeBytes: tier1FreeBytes, + tier1SendMs: tier1SendMs, + tier2FreeBytes: tier2FreeBytes, + tier2SendMs: tier2SendMs, + tier3FreeBytes: tier3FreeBytes, + tier3SendMs: tier3SendMs, ); } catch (e) { Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index b264922e9..cb63f1b51 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -122,7 +122,7 @@ class AzureAstService extends GetxService implements AstService { final String utteranceId = eventMap['utteranceId'] as String? ?? ''; final String text = eventMap['text'] as String? ?? ''; final String detectedLanguage = eventMap['language'] as String? ?? ''; - Logger.d('AzureAST', '[STS] 识别中 [$serviceId]: text=$text'); + // [STS] 识别中(增量过渡,每字刷屏,已关闭;最终结果见"识别完成") _eventStreamController?.add(ASTEvent( type: ASTEventType.intermediateResult, serviceId: serviceId, @@ -137,7 +137,7 @@ class AzureAstService extends GetxService implements AstService { final String text = eventMap['translatedText'] as String? ?? ''; final String detectedLanguage = eventMap['targetLanguage'] as String? ?? ''; - Logger.d('AzureAST', '[STS] 翻译中 [$serviceId]: text=$text'); + // [STS] 翻译中(增量过渡,每字刷屏,已关闭;最终结果见"翻译完成") _eventStreamController?.add(ASTEvent( type: ASTEventType.translationInterim, serviceId: serviceId, diff --git a/lib/main.dart b/lib/main.dart index c540024f2..9dfd9d587 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,18 @@ void main() async { // 初始化文件日志 await Logger.initFileLog(); + // 监听 native 通话翻译日志上报,统一写入 Dart 的同一个 app_*.log(一次启动只有一个日志文件) + const EventChannel('com.yunqiinnovation.ble_service/call_log_events') + .receiveBroadcastStream() + .listen((e) { + if (e is String) Logger.appendNative(e); + }); + const EventChannel('azure_speech/call_log_events') + .receiveBroadcastStream() + .listen((e) { + if (e is String) Logger.appendNative(e); + }); + // 初始化登录服务 Get.put(LoginService()); diff --git a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart b/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart index 703fd5af3..682b4a652 100644 --- a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart +++ b/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart @@ -5,7 +5,7 @@ import '../../../data/services/ble_manager.dart'; import '../../../core/utils/logger.dart'; /// 通话翻译调试控制器 -/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发, +/// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发, /// 以及通话翻译编解码的启动/停止。 class CallTranslationDebugController extends GetxController { final BleManager _bleManager = Get.find(); @@ -14,10 +14,23 @@ class CallTranslationDebugController extends GetxController { // 持久化键 static const String _kSendInterval = 'call_trans_debug_send_interval'; static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; + static const String _kTier1Free = 'call_trans_debug_tier1_free'; + static const String _kTier1Send = 'call_trans_debug_tier1_send'; + static const String _kTier2Free = 'call_trans_debug_tier2_free'; + static const String _kTier2Send = 'call_trans_debug_tier2_send'; + static const String _kTier3Free = 'call_trans_debug_tier3_free'; + static const String _kTier3Send = 'call_trans_debug_tier3_send'; // 默认值(与原生默认保持一致) static const int defaultSendIntervalMs = 100; - static const int defaultBundleFrameCount = 4; + static const int defaultBundleFrameCount = 5; + // 三档控流默认:≥2880B→40ms / ≥1120B→160ms / 其余→320ms(对齐原生默认) + static const int defaultTier1Free = 2880; + static const int defaultTier1Send = 40; + static const int defaultTier2Free = 1120; + static const int defaultTier2Send = 160; + static const int defaultTier3Free = 0; + static const int defaultTier3Send = 320; // 取值范围 static const int minSendIntervalMs = 20; @@ -25,6 +38,14 @@ class CallTranslationDebugController extends GetxController { static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int minBundleFrameCount = 1; static const int maxBundleFrameCount = 20; + // 三档:剩余字节数门限范围(设备按约 160B/包 步进上报,故用 160 步长) + static const int minTierFree = 0; + static const int maxTierFree = 4000; + static const int tierFreeStep = 160; + // 三档:发送间隔范围(取拍长整数倍更干净,用 40ms 步长) + static const int minTierSendMs = 40; + static const int maxTierSendMs = 800; + static const int tierSendStepMs = 40; /// 音频下行发送间隔(ms) final RxInt sendIntervalMs = defaultSendIntervalMs.obs; @@ -32,6 +53,14 @@ class CallTranslationDebugController extends GetxController { /// 下行合包帧数 final RxInt bundleFrameCount = defaultBundleFrameCount.obs; + /// 三档控流:每档 剩余字节数门限 + 发送间隔(ms) + final RxInt tier1Free = defaultTier1Free.obs; + final RxInt tier1Send = defaultTier1Send.obs; + final RxInt tier2Free = defaultTier2Free.obs; + final RxInt tier2Send = defaultTier2Send.obs; + final RxInt tier3Free = defaultTier3Free.obs; + final RxInt tier3Send = defaultTier3Send.obs; + /// 通话翻译编解码是否已开启 RxBool get isCodecActive => _bleManager.isCodecActiveRx; @@ -58,14 +87,24 @@ class CallTranslationDebugController extends GetxController { bundleFrameCount.value = savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); } + _loadTier(_kTier1Free, tier1Free, minTierFree, maxTierFree); + _loadTier(_kTier1Send, tier1Send, minTierSendMs, maxTierSendMs); + _loadTier(_kTier2Free, tier2Free, minTierFree, maxTierFree); + _loadTier(_kTier2Send, tier2Send, minTierSendMs, maxTierSendMs); + _loadTier(_kTier3Free, tier3Free, minTierFree, maxTierFree); + _loadTier(_kTier3Send, tier3Send, minTierSendMs, maxTierSendMs); + } + + void _loadTier(String key, RxInt target, int lo, int hi) { + final v = _storage.read(key); + if (v != null) target.value = v.clamp(lo, hi); } - /// 用原生当前实际值同步显示(不覆盖用户本地保存的偏好,仅在无本地保存时参考) + /// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值) Future _syncFromNative() async { try { final params = await _bleManager.getCallTranslationDebugParams(); if (params.isEmpty) return; - // 仅当本地没有保存过时,采用原生返回值 if (_storage.read(_kSendInterval) == null) { final ni = params['sendIntervalMs']; if (ni != null && ni > 0) { @@ -79,15 +118,33 @@ class CallTranslationDebugController extends GetxController { nf.clamp(minBundleFrameCount, maxBundleFrameCount); } } + _syncTier(params, 'tier1FreeBytes', _kTier1Free, tier1Free, minTierFree, + maxTierFree); + _syncTier(params, 'tier1SendMs', _kTier1Send, tier1Send, minTierSendMs, + maxTierSendMs); + _syncTier(params, 'tier2FreeBytes', _kTier2Free, tier2Free, minTierFree, + maxTierFree); + _syncTier(params, 'tier2SendMs', _kTier2Send, tier2Send, minTierSendMs, + maxTierSendMs); + _syncTier(params, 'tier3FreeBytes', _kTier3Free, tier3Free, minTierFree, + maxTierFree); + _syncTier(params, 'tier3SendMs', _kTier3Send, tier3Send, minTierSendMs, + maxTierSendMs); } catch (e) { Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e'); } } + void _syncTier(Map params, String nativeKey, String storeKey, + RxInt target, int lo, int hi) { + // 剩余字节数下限可为 0,故不用 >0 过滤;仅在本地未保存过时采用原生值 + if (_storage.read(storeKey) != null) return; + final v = params[nativeKey]; + if (v != null) target.value = v.clamp(lo, hi); + } + void setSendInterval(int value) { - // 按 20ms 步长对齐 - final snapped = - (value / sendIntervalStepMs).round() * sendIntervalStepMs; + final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs; sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs); } @@ -96,22 +153,53 @@ class CallTranslationDebugController extends GetxController { value.clamp(minBundleFrameCount, maxBundleFrameCount); } + void setTierFree(RxInt target, int value) { + final snapped = (value / tierFreeStep).round() * tierFreeStep; + target.value = snapped.clamp(minTierFree, maxTierFree); + } + + void setTierSend(RxInt target, int value) { + final snapped = (value / tierSendStepMs).round() * tierSendStepMs; + target.value = snapped.clamp(minTierSendMs, maxTierSendMs); + } + /// 恢复默认值 void resetDefaults() { sendIntervalMs.value = defaultSendIntervalMs; bundleFrameCount.value = defaultBundleFrameCount; + tier1Free.value = defaultTier1Free; + tier1Send.value = defaultTier1Send; + tier2Free.value = defaultTier2Free; + tier2Send.value = defaultTier2Send; + tier3Free.value = defaultTier3Free; + tier3Send.value = defaultTier3Send; } /// 保存并下发参数到原生(运行时立即生效) Future applyParams() async { _storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kBundleFrameCount, bundleFrameCount.value); + _storage.write(_kTier1Free, tier1Free.value); + _storage.write(_kTier1Send, tier1Send.value); + _storage.write(_kTier2Free, tier2Free.value); + _storage.write(_kTier2Send, tier2Send.value); + _storage.write(_kTier3Free, tier3Free.value); + _storage.write(_kTier3Send, tier3Send.value); final ok = await _bleManager.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs.value, bundleFrameCount: bundleFrameCount.value, + tier1FreeBytes: tier1Free.value, + tier1SendMs: tier1Send.value, + tier2FreeBytes: tier2Free.value, + tier2SendMs: tier2Send.value, + tier3FreeBytes: tier3Free.value, + tier3SendMs: tier3Send.value, ); Logger.i('CALL_TRANS_DEBUG', - '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok'); + '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} ' + '档1(≥${tier1Free.value}B→${tier1Send.value}ms) ' + '档2(≥${tier2Free.value}B→${tier2Send.value}ms) ' + '档3(其余→${tier3Send.value}ms) result=$ok'); Get.snackbar( '通话翻译调试', ok ? '参数已应用' : '参数下发失败(请确认设备已连接)', diff --git a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart b/lib/modules/call_translation_debug/views/call_translation_debug_view.dart index 7221bf98b..ad6c5ff66 100644 --- a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart +++ b/lib/modules/call_translation_debug/views/call_translation_debug_view.dart @@ -36,6 +36,8 @@ class CallTranslationDebugView _buildSendIntervalCard(isDark), SizedBox(height: 16.h), _buildBundleFrameCard(isDark), + SizedBox(height: 16.h), + _buildFlowControlCard(isDark), SizedBox(height: 24.h), _buildActionButtons(isDark), ], @@ -193,6 +195,104 @@ class CallTranslationDebugView ); } + // 下行控流三档:每档 剩余字节数门限 + 发送间隔 + Widget _buildFlowControlCard(bool isDark) { + return _card( + isDark, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _title(isDark, '下行控流三档', + '按设备上报的解码空余字节数分三档控制发送频率:空余越少发得越慢,防设备解码溢出丢包。' + '规则:从第一档往下取第一个满足「空余 ≥ 门限」的档,用该档发送间隔。'), + SizedBox(height: 8.h), + _tierBlock(isDark, '第一档(最快)', controller.tier1Free, controller.tier1Send), + Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12), + _tierBlock(isDark, '第二档(中速)', controller.tier2Free, controller.tier2Send), + Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12), + _tierBlock(isDark, '第三档(最慢·其余)', controller.tier3Free, controller.tier3Send), + ], + ), + ); + } + + // 单个档位:两条滑杆(剩余字节数门限 + 发送间隔ms) + Widget _tierBlock(bool isDark, String label, RxInt freeRx, RxInt sendRx) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 4.h), + _tierSliderRow( + isDark, + '剩余字节数门限', + freeRx, + min: CallTranslationDebugController.minTierFree, + max: CallTranslationDebugController.maxTierFree, + step: CallTranslationDebugController.tierFreeStep, + unit: 'B', + onChanged: (v) => controller.setTierFree(freeRx, v), + ), + _tierSliderRow( + isDark, + '发送间隔', + sendRx, + min: CallTranslationDebugController.minTierSendMs, + max: CallTranslationDebugController.maxTierSendMs, + step: CallTranslationDebugController.tierSendStepMs, + unit: 'ms', + onChanged: (v) => controller.setTierSend(sendRx, v), + ), + ], + ); + } + + Widget _tierSliderRow( + bool isDark, + String name, + RxInt valueRx, { + required int min, + required int max, + required int step, + required String unit, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox( + width: 96.w, + child: Text( + name, + style: TextStyle( + fontSize: 12.sp, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + ), + Expanded( + child: Obx( + () => Slider( + value: valueRx.value.clamp(min, max).toDouble(), + min: min.toDouble(), + max: max.toDouble(), + divisions: (max - min) ~/ step, + label: '${valueRx.value} $unit', + onChanged: (v) => onChanged(v.round()), + ), + ), + ), + Obx(() => _valueChip(isDark, '${valueRx.value} $unit')), + ], + ); + } + Widget _title(bool isDark, String title, String subtitle) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/modules/log_export/controllers/log_export_controller.dart b/lib/modules/log_export/controllers/log_export_controller.dart index b65ce56a7..db759ac39 100644 --- a/lib/modules/log_export/controllers/log_export_controller.dart +++ b/lib/modules/log_export/controllers/log_export_controller.dart @@ -91,6 +91,26 @@ class LogExportController extends GetxController { } } + /// 删除单个日志文件 + Future deleteSingleLog(LogFileInfo logFile) async { + try { + if (await logFile.file.exists()) { + await logFile.file.delete(); + } + logFiles.remove(logFile); + // 重算总大小 + int total = 0; + for (final f in logFiles) { + total += (await f.file.stat()).size; + } + totalSize.value = _formatSize(total); + Get.snackbar('提示', '已删除 ${logFile.name}'); + } catch (e) { + Logger.e('LogExport', '删除日志失败: $e'); + Get.snackbar('错误', '删除失败: $e'); + } + } + /// 清除所有日志 Future clearAllLogs() async { try { diff --git a/lib/modules/log_export/views/log_export_view.dart b/lib/modules/log_export/views/log_export_view.dart index 21d29d9bf..770895c0c 100644 --- a/lib/modules/log_export/views/log_export_view.dart +++ b/lib/modules/log_export/views/log_export_view.dart @@ -181,6 +181,12 @@ class LogExportView extends GetView { color: isDarkMode ? Colors.blue[300] : Colors.blue), onPressed: () => controller.shareSingleLog(logFile), ), + IconButton( + icon: Icon(Icons.delete_outline, + size: 20.sp, + color: isDarkMode ? Colors.red[300] : Colors.red[400]), + onPressed: () => controller.deleteSingleLog(logFile), + ), ], ), ), diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index c93f2bac4..4b5e3741e 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -206,7 +206,24 @@ class OpusTestController extends GetxController { void selectExternalFile(PlatformFile file) { selectedFiles.clear(); selectedFiles.add(file); - statusMessage.value = '已选择文件: ${file.name}'; + // 「翻译-*」文件自动套用手机端下行 opus 解码参数,套上后提示里带说明;否则只提示已选择 + if (!_applyAutoDecodeParams(file.name)) { + statusMessage.value = '已选择文件: ${file.name}'; + } + } + + /// 「翻译-*」文件(手机端下行 opus 录音)自动套用其编码参数: + /// hasHeader=false / 单声道 / 16kHz / packetSize=40,与 BleService 的 + /// startDualEncodeStream(false, 16000, 40) 对齐,省去手动改包长。 + /// @return 是否命中并套用了参数 + bool _applyAutoDecodeParams(String name) { + if (!name.startsWith('翻译-')) return false; + hasHeader.value = false; + isMono.value = true; + selectedSampleRate.value = 16000; + packetLength.value = '40'; + statusMessage.value = '已选择翻译音频,自动套用解码参数(16k/单声道/40):${name}'; + return true; } // 切换通道模式 @@ -409,6 +426,84 @@ class OpusTestController extends GetxController { } } + /// 一键清理:删除当前列表中扫描到的所有文件 + Future clearAllFiles() async { + if (externalFiles.isEmpty) { + statusMessage.value = '没有可清理的文件'; + return; + } + + final total = externalFiles.length; + int success = 0; + int failed = 0; + + // 复制一份遍历,避免删除过程中修改原列表 + final files = List.from(externalFiles); + for (final file in files) { + try { + if (file.path == null) { + failed++; + continue; + } + final f = File(file.path!); + if (await f.exists()) { + await f.delete(); + } + success++; + } catch (e) { + failed++; + } + } + + externalFiles.clear(); + selectedFiles.clear(); + statusMessage.value = failed == 0 + ? '已清理全部 $success 个文件' + : '清理完成:成功 $success 个,失败 $failed 个(共 $total 个)'; + } + + /// 一键清理确认对话框 + void confirmClearAll(BuildContext context) { + if (externalFiles.isEmpty) { + statusMessage.value = '没有可清理的文件'; + return; + } + + final count = externalFiles.length; + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white, + title: Text( + '确认清理', + style: TextStyle( + color: Get.isDarkMode ? Colors.white : Colors.black, + ), + ), + content: Text( + '确定要删除列表中全部 $count 个文件吗?\n此操作不可撤销。', + style: TextStyle( + color: Get.isDarkMode ? Colors.white70 : Colors.black87, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + clearAllFiles(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('全部删除'), + ), + ], + ), + ); + } + /// 分享外部文件 Future shareExternalFile(PlatformFile file) async { try { diff --git a/lib/modules/opus_test/views/opus_test_view.dart b/lib/modules/opus_test/views/opus_test_view.dart index de02e5138..7a389089f 100644 --- a/lib/modules/opus_test/views/opus_test_view.dart +++ b/lib/modules/opus_test/views/opus_test_view.dart @@ -109,12 +109,30 @@ class OpusTestView extends GetView { color: isDarkMode ? Colors.white : Colors.black, ), ), - IconButton( - icon: Icon( - Icons.refresh, - color: isDarkMode ? Colors.blue[300] : Colors.blue, - ), - onPressed: controller.loadExternalFiles, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 一键清理:仅在列表非空时显示 + Obx(() => controller.externalFiles.isEmpty + ? const SizedBox.shrink() + : IconButton( + icon: const Icon( + Icons.delete_sweep, + color: Colors.red, + ), + tooltip: '一键清理', + onPressed: () => + controller.confirmClearAll(Get.context!), + )), + IconButton( + icon: Icon( + Icons.refresh, + color: isDarkMode ? Colors.blue[300] : Colors.blue, + ), + tooltip: '刷新', + onPressed: controller.loadExternalFiles, + ), + ], ), ], ), diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 1c1bc40a1..3382ffdde 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -146,9 +146,16 @@ object BleAgent : BleService.Callback, AgentServiceListener { if (state == BleConst.STATE_CONNECTED) { Log.d(TAG, "Ble设备已连接,准备好语音交互") } else if (state == BleConst.STATE_DISCONNECTED) { - Log.d(TAG, "Ble设备已断开,停止语音服务") - // 设备断开连接,停止语音服务 - stopListening() + // 通话翻译进行中的断连:不要在这里 stopListening()(它会 closeCodec 拆掉会话), + // 交给 BleService 内部的自动回连(暂停下行→回连→重进翻译态)接管,否则会话被拆无法恢复。 + // 仅当"确实在通话翻译状态"才跳过;其它场景(AI 助手等)保持原有断开即停服。 + if (BleService.isInCallTranslation()) { + Log.d(TAG, "Ble设备断开但处于通话翻译中,交由 BleService 自动回连,暂不停止语音服务") + } else { + Log.d(TAG, "Ble设备已断开,停止语音服务") + // 设备断开连接,停止语音服务 + stopListening() + } } } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt index 1ff8552fa..a4e6ddf51 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt @@ -526,10 +526,22 @@ class DoubaoAstCallback( private val tag = "DoubaoAstCallback" private val doubaoFinalSourceTextCache: MutableMap = mutableMapOf() - // 延迟追踪:每句只在"距上一帧>700ms"(视为新一句)时打一条 [LAT-TRACE],避免逐帧刷屏。 - // 同进程 currentTimeMillis 与 BleService 侧可直接相减,定位 app 内部各跳耗时。 - private var latLastTextTs = 0L - private var latLastAudioTs = 0L + // 延迟追踪:每句(key=sessionId)记录各里程碑时刻,句结束时汇总为一行 [LAT]。 + // LongArray: [0]=句起点(首个事件) [1]=识别定稿 [2]=首个译文文字 [3]=首个译音。 + private val latMarks = HashMap() + private fun latMark(sid: String): LongArray = synchronized(latMarks) { + latMarks.getOrPut(sid) { longArrayOf(System.currentTimeMillis(), 0L, 0L, 0L) } + } + // 句结束汇总:各段相对句起点的耗时,一句一行;remove 保证只打一次 + // (onSessionFinished / onFinalTranslatedText 谁先到谁打)。 + private fun flushLat(sessionId: String, finalText: String) { + val m = synchronized(latMarks) { latMarks.remove(sessionId) } ?: return + // 基准=识别定稿;出译文=定稿→出译文(翻译耗时),出译音=定稿→出首帧译音(翻译+TTS) + val base = if (m[1] > 0L) m[1] else m[0] + val now = System.currentTimeMillis() + fun seg(t: Long) = if (t > 0L) "+${t - base}ms" else "-" + CallLog.i(tag, "[LAT] svc=$serviceId 出译文${seg(m[2])} 出译音${seg(m[3])} 句时长${now - base}ms text=\"${finalText.take(16)}\"") + } override fun onSessionStarted(sessionId: String) { eventSender.send( @@ -558,6 +570,7 @@ class DoubaoAstCallback( override fun onFinalSourceText(sessionId: String, finalText: String) { val key = "$serviceId:$sessionId" doubaoFinalSourceTextCache[key] = finalText + latMark(sessionId).let { if (it[1] == 0L) it[1] = System.currentTimeMillis() } // 识别定稿 eventSender.send( mapOf( "type" to "recognized", @@ -571,12 +584,7 @@ class DoubaoAstCallback( } override fun onPartialText(sessionId: String, text: String) { - // [LAT-TRACE] 点1:收到译文文字(每句首条,svc=B 即对方) - val latNow = System.currentTimeMillis() - if (latNow - latLastTextTs > 700) { - FileLogger.i(tag, "[LAT-TRACE] 1.译文文字到达 svc=$serviceId ts=$latNow text=${text.take(12)}") - } - latLastTextTs = latNow + latMark(sessionId).let { if (it[2] == 0L) it[2] = System.currentTimeMillis() } // 首个译文文字 eventSender.send( mapOf( "type" to "translatedInterim", @@ -591,12 +599,7 @@ class DoubaoAstCallback( } override fun onPartialAudio(sessionId: String, data: ByteArray) { - // [LAT-TRACE] 点2:app 收到译音 PCM(每句首帧,svc=B 即对方) - val latNow = System.currentTimeMillis() - if (latNow - latLastAudioTs > 700) { - FileLogger.i(tag, "[LAT-TRACE] 2.app收到译音 svc=$serviceId ts=$latNow size=${data.size}") - } - latLastAudioTs = latNow + latMark(sessionId).let { if (it[3] == 0L) it[3] = System.currentTimeMillis() } // 首个译音 audioWriter?.write(data) } @@ -620,9 +623,12 @@ class DoubaoAstCallback( // if (finalAudio.isNotEmpty()) { // audioWriter?.write(finalAudio) // } + + flushLat(sessionId, finalText) // 句结束:汇总本句延迟为一行 } override fun onSessionError(sessionId: String, code: Int, message: String) { + CallLog.e(tag, "[AST-ERR] 豆包端到端会话错误 svc=$serviceId code=$code msg=$message") eventSender.send( mapOf( "type" to "error", @@ -638,6 +644,7 @@ class DoubaoAstCallback( override fun onFinalTranslatedText(sessionId: String, finalText: String) { val key = "$serviceId:$sessionId" val original = doubaoFinalSourceTextCache.remove(key) ?: "" + flushLat(sessionId, finalText) // 句结束:汇总本句延迟为一行(与 onSessionFinished 互斥,remove 保证只打一次) eventSender.send( mapOf( "type" to "translated", 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 ec9c07e46..30a102024 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 @@ -72,6 +72,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private lateinit var astEventChannel: EventChannel private var astEventSink: EventChannel.EventSink? = null + // 通话翻译日志上报通道:native CallLog 的日志经此发往 Dart,由 Dart Logger 统一写入 app_*.log + private var callLogEventChannel: EventChannel? = null + // 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器 private val callBPcmPlayer = CallBPcmPlayer() // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 @@ -366,6 +369,18 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, astEventSink = null } }) + + // 通话翻译日志上报通道:native CallLog 的日志经此发往 Dart,由 Dart Logger 统一写入 app_*.log(单一日志文件) + callLogEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/call_log_events") + callLogEventChannel?.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + CallLog.setEmitter { line -> mainHandler.post { try { events?.success(line) } catch (_: Exception) {} } } + } + + override fun onCancel(arguments: Any?) { + CallLog.setEmitter(null) + } + }) // 初始化翻译事件通道 translationEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events") @@ -1844,6 +1859,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, ttsEventChannel.setStreamHandler(null) astEventChannel.setStreamHandler(null) translationEventChannel.setStreamHandler(null) + callLogEventChannel?.setStreamHandler(null) + CallLog.setEmitter(null) try { if (isRegisteredToBle) { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallLog.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallLog.kt new file mode 100644 index 000000000..84a54ac03 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallLog.kt @@ -0,0 +1,72 @@ +package com.yunqiinnovation.azure_speech + +import android.util.Log +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 通话翻译日志(azure_speech 侧:上行门控、AST 端到端翻译、译音下发、延迟埋点)。 + * + * 与 ble_service 侧 CallLog 同构:不自己写文件,而是把预格式化日志行**批量上报给 Dart**, + * 由 Dart `Logger` 统一写入唯一的 `app_*.log`。 + * - 毫秒时间戳在产生点(native)打; + * - **批量上报**:后台线程每 [FLUSH_INTERVAL_MS] 把整批拼成一条 payload 上报一次, + * 避免每条日志都 `mainHandler.post` 到主线程导致通话翻译卡顿; + * - emitter 未就绪前入队缓存,就绪后一并发出;保留 logcat。 + */ +object CallLog { + private const val TAG = "CallLog" + private const val MAX_QUEUE = 8000 + private const val FLUSH_INTERVAL_MS = 200L + + private val tsFmt = ThreadLocal.withInitial { SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) } + + private val buffer = LinkedBlockingQueue() + @Volatile private var emitter: ((String) -> Unit)? = null + private val started = AtomicBoolean(false) + + /** 由插件在日志 EventChannel onListen/onCancel 时调用:设置/解绑上报回调;设置时启动 flusher。 */ + fun setEmitter(e: ((String) -> Unit)?) { + emitter = e + if (e != null) startFlusher() + } + + fun d(tag: String, message: String) { Log.d(tag, message); enqueue("DEBUG", tag, message) } + fun i(tag: String, message: String) { Log.i(tag, message); enqueue("INFO", tag, message) } + fun w(tag: String, message: String) { Log.w(tag, message); enqueue("WARN", tag, message) } + fun e(tag: String, message: String) { Log.e(tag, message); enqueue("ERROR", tag, message) } + + private fun enqueue(level: String, tag: String, msg: String) { + val line = "${tsFmt.get().format(Date())} [$level][$tag] $msg" + while (buffer.size >= MAX_QUEUE) buffer.poll() + buffer.offer(line) + } + + private fun startFlusher() { + if (started.getAndSet(true)) return + Thread({ + while (true) { + try { + Thread.sleep(FLUSH_INTERVAL_MS) + flush() + } catch (e: InterruptedException) { + break + } catch (e: Exception) { + Log.e(TAG, "CallLog flush 异常: ${e.message}") + } + } + }, "CallLogFlusher-Ast").apply { isDaemon = true; start() } + } + + private fun flush() { + val e = emitter ?: return + if (buffer.isEmpty()) return + val batch = ArrayList(buffer.size) + buffer.drainTo(batch) + if (batch.isEmpty()) return + try { e(batch.joinToString("\n")) } catch (_: Exception) {} + } +} diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt index 270b86407..5e8bcd753 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt @@ -604,7 +604,7 @@ class BleCommandSender { BleConst.AUDIO_CHANNEL_LEFT else BleConst.AUDIO_CHANNEL_RIGHT if (notifyData.size >= 5) { val freeBytes = readInt32BE(notifyData, 1) - Log.i(TAG, "收到主动上报: ${if (channel == BleConst.AUDIO_CHANNEL_LEFT) "左" else "右"}声道解码空余=$freeBytes 字节 (code=0x${codecStatus.toString(16)})") + CallLog.i(TAG, "收到主动上报: ${if (channel == BleConst.AUDIO_CHANNEL_LEFT) "左" else "右"}声道解码空余=$freeBytes 字节 (code=0x${codecStatus.toString(16)})") callback?.notifyCallDecodeFreeReported(channel, freeBytes) } else { Log.w(TAG, "解码空余上报数据长度不足: ${notifyData.size}(期望>=5),忽略 code=0x${codecStatus.toString(16)}") @@ -636,7 +636,7 @@ class BleCommandSender { BleConst.CMD_CALL_TRANSLATION_ON -> { // 设备上报: 开启通话翻译 0x16 (cmdLen=0x01, status=0xFF) val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 - Log.i(TAG, "[CALL_TRANS_CMD] 收到设备上报开启通话翻译 cmd=0x16 status=0x${"%02X".format(status)}") + CallLog.i(TAG, "[CALL_TRANS_CMD] 收到设备上报开启通话翻译 cmd=0x16 status=0x${"%02X".format(status)}") val resultMap = mapOf( "success" to true, "command" to infoType, @@ -649,7 +649,7 @@ class BleCommandSender { BleConst.CMD_CALL_TRANSLATION_OFF -> { // 设备上报: 关闭通话翻译 0x17 (cmdLen=0x01, status=0xFF) val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 - Log.i(TAG, "[CALL_TRANS_CMD] 收到设备上报关闭通话翻译 cmd=0x17 status=0x${"%02X".format(status)}") + CallLog.i(TAG, "[CALL_TRANS_CMD] 收到设备上报关闭通话翻译 cmd=0x17 status=0x${"%02X".format(status)}") val resultMap = mapOf( "success" to true, "command" to infoType, 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 8d56ac63f..072318720 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 @@ -95,7 +95,9 @@ object BleService { private var callAudioChar: BluetoothGattCharacteristic? = null private var callWriteChar: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null - var recordfile1: RecordingFile? = null + // 手机端下行 opus 录音按声道分文件:左右各自独立编码,混写到一个文件无法解码听辨 + var recordfile1Left: RecordingFile? = null + var recordfile1Right: RecordingFile? = null // 扫描相关 private lateinit var scanHandler: Handler @@ -147,9 +149,50 @@ object BleService { // 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏 private var latLastBleRightTs = 0L + // ---- 通话翻译断连自动回连 / 下行暂停 ---- + // 是否处于通话翻译会话中(openA2DPDecoder 置位, closeCodec 清零);只有它为真才在意外断连时快速主动回连 + @Volatile + private var isCallTranslating = false + // 是否用户/正常主动断开(disconnect/closeCodec):为真时不触发自动回连,避免误连 + @Volatile + private var isManualDisconnect = false + // 下行音频是否暂停(重连窗口内):发送线程检测到即只抽干主队列到左右声道缓存暂存、不写 BLE; + // 译音不丢弃,回连成功后按节拍续发缓存内容(超大兜底 CHANNEL_HOLD_BUFFER_MAX 才丢最旧防 OOM) + @Volatile + private var isDownlinkPaused = false + // 是否正处于通话翻译自动回连流程中(用于回连成功后在 onServicesDiscovered 判断是否需要重进翻译态) + @Volatile + private var isReconnecting = false + // 当前已发起的主动回连次数;超过上限回退到后台扫描回连 + private var reconnectAttempts = 0 + // 通话翻译断连自动回连的延迟与上限 + private const val RECONNECT_DELAY_MS = 1000L // 断开后等待 1s 再发起回连 + private const val MAX_RECONNECT_ATTEMPTS = 5 // 最多主动回连 5 次, 仍失败回退后台扫描 + // 专用回连 Handler(便于 removeCallbacks 干净取消) + private val reconnectHandler = Handler(Looper.getMainLooper()) + // 回连执行体:延迟到点后按已配对 MAC 主动回连 + private val reconnectRunnable = Runnable { + // 回连前再校验:翻译已结束或已转为主动断开则放弃 + if (!isCallTranslating || isManualDisconnect) { + CallLog.i(TAG, "[CALL_RECONNECT] 回连前状态已变化(翻译结束或主动断开),取消主动回连") + isReconnecting = false + return@Runnable + } + val pairedMac = getPairedMacAddress() + if (pairedMac.isNullOrEmpty()) { + CallLog.w(TAG, "[CALL_RECONNECT] 无配对 MAC,无法主动回连,回退后台扫描") + isReconnecting = false + val pairedUuid = companionManager?.getPairedUuid() + companionManager?.registerBackgroundScan(pairedMac, pairedUuid) + return@Runnable + } + CallLog.i(TAG, "[CALL_RECONNECT] 发起第 $reconnectAttempts 次主动回连 → connect($pairedMac)") + connect(pairedMac) + } + // 音频数据分块发送的常量 private val AUDIO_CHUNK_SIZE = 120 // 每次发送120字节 - private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 40L // 默认音频下行发送间隔(ms) + private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 80L // 默认音频下行发送间隔(ms) // 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 @Volatile private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL @@ -171,15 +214,16 @@ object BleService { private const val CHANNEL_HOLD_BUFFER_MAX = 10000 // 左右同一拍都满足发送条件时的轮转标记,保证两声道公平、避免一方饿死。 private var holdDrainPreferLeft = true - // ---- F4/F5 解码空余字节数 → 下发档位阈值(带迟滞)---- - // 设备解码缓存总量≈1840B、单包160B(≈11.5包),按包步进上报。边界取 160(单包)整数倍, - // 正好落在相邻上报值中间,避免临界抖动。降档敏感(及早减速防溢出)、升档保守(回升够多才提速), - // 中间留 2 包迟滞带,消除边界反复横跳。 - // 阈值整体上调(补偿空余上报的网络延迟、留提前量):相对初始已上调 4 档(+640B≈4包),更早降速、更晚升速。 - private const val DECODE_FREE_CRIT_DOWN = 1120 // 空余 ≤7包:紧急降到 320ms(8x) - private const val DECODE_FREE_CRIT_UP = 1440 // 处于 320ms 时,回升到 ≥9包 才升回 160ms(4x) - private const val DECODE_FREE_NORMAL_DOWN = 1920 // 处于 40ms 时,跌到 ≤12包 才降到 160ms(4x) - private const val DECODE_FREE_NORMAL_UP = 2880 // 升到 ≥18包 才回 40ms(1x) 全速 + // ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ---- + // 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后, + // 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。 + // 无迟滞(直观好调);如遇边界抖动可再引入固定小迟滞。默认对齐历史行为(≥2880→40ms / ≥1120→160ms / 其余→320ms)。 + @Volatile private var tier1FreeBytes = 2880 // 第一档(最快):空余 ≥ 此值 + @Volatile private var tier1SendMs = 40L // 第一档发送间隔(ms/包) + @Volatile private var tier2FreeBytes = 1120 // 第二档(中速):空余 ≥ 此值 + @Volatile private var tier2SendMs = 160L // 第二档发送间隔(ms/包) + @Volatile private var tier3FreeBytes = 0 // 第三档(最慢):其余(空余 < 第二档门限),此门限为兜底下界 + @Volatile private var tier3SendMs = 320L // 第三档发送间隔(ms/包) // 音频数据缓冲区,用于累积数据到80字节再发送 private val audioBuffer = mutableListOf() // 重发机制相关常量 @@ -272,8 +316,12 @@ object BleService { recordfile = RecordingFile(this.context) recordfile!!.fileName = "耳机端" - recordfile1 = RecordingFile(this.context) - recordfile1!!.fileName = "手机端" + // 手机端下行按左右声道分别录制,便于 opus_test 分声道听辨 + // 命名用「翻译-*」前缀,opus_test 选中时自动套用手机端 opus 解码参数(16k/单/40/无头) + recordfile1Left = RecordingFile(this.context) + recordfile1Left!!.fileName = "翻译-左" + recordfile1Right = RecordingFile(this.context) + recordfile1Right!!.fileName = "翻译-右" // 初始化Opus音频管理器(传入 context 以支持双声道编码时分别写 PCM WAV 调试文件) if (!opusAudioManager.initialize(this.context)) { @@ -828,6 +876,9 @@ object BleService { */ fun disconnect() { Log.d(TAG, "主动断开当前连接") + // 用户/正常主动断开:标记后取消任何进行中的通话翻译自动回连,避免断开又被自动连回 + isManualDisconnect = true + cancelCallTranslationReconnect() bluetoothGatt?.disconnect() updateConnectionState(BleConst.STATE_DISCONNECTING) } @@ -890,6 +941,10 @@ object BleService { status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> { updateConnectionState(BleConst.STATE_CONNECTED) Log.d(TAG, "成功连接Ble蓝牙设备:${g.device.name}") + // 通话翻译回连窗口内:GATT 已连上,记入 app 日志便于追踪整个回连过程 + if (isDownlinkPaused) { + CallLog.i(TAG, "[CALL_RECONNECT] GATT 回连已连上,等待服务发现 dev=${g.device.name}") + } companionManager?.unregisterBackgroundScan() g.discoverServices() @@ -898,16 +953,34 @@ object BleService { newState == BluetoothProfile.STATE_DISCONNECTED -> { updateConnectionState(BleConst.STATE_DISCONNECTED) Log.d(TAG, "监听到已断开连接") - + disconnectGatt() - val pairedUuid = companionManager?.getPairedUuid() - val pairedMac = companionManager?.getPairedMacAddress() - companionManager?.registerBackgroundScan(pairedMac, pairedUuid) + // 通话翻译进行中且非主动断开 → 走快速主动回连(暂停下行+1s后回连); + // 其余场景(非翻译/主动断开/回连已达上限)维持原有后台扫描回连 + if (isCallTranslating && !isManualDisconnect) { + if (isReconnecting) { + CallLog.w(TAG, "[CALL_RECONNECT] 第 $reconnectAttempts 次回连未成功(设备又断开),准备重试") + } else { + CallLog.w(TAG, "[CALL_RECONNECT] 通话翻译中检测到 BLE 断开,启动主动回连") + } + scheduleCallTranslationReconnect() + } else { + if (isCallTranslating && isManualDisconnect) { + CallLog.i(TAG, "[CALL_RECONNECT] 通话翻译中为主动断开,不自动回连,改后台扫描") + } + val pairedUuid = companionManager?.getPairedUuid() + val pairedMac = companionManager?.getPairedMacAddress() + companionManager?.registerBackgroundScan(pairedMac, pairedUuid) + } } else -> { updateConnectionState(BleConst.STATE_ERROR) Log.d(TAG, "监听到连接错误") + // 回连过程中的连接错误(如 GATT 133)记入 app 日志,便于定位回连失败原因 + if (isDownlinkPaused || isReconnecting) { + CallLog.w(TAG, "[CALL_RECONNECT] 回连过程出现连接错误 status=$status newState=$newState") + } disconnectGatt() } } @@ -1034,6 +1107,17 @@ object BleService { // 服务发现完成后,请求高优先级连接参数(最短连接间隔),提升 BLE 双向吞吐、 // 缓解上下行互相挤占导致的收发失败。连接断开后系统自动复位,无需手动还原。 requestHighConnectionPriority(g) + + // 若本次连接发生在通话翻译的重连窗口(下行处于暂停待恢复):等 CCCD 串行注册完成后, + // 重下 A2DP_PLAY 让设备重新进入翻译播放态,并恢复下行音频、续发暂停期间缓存的译音。 + // 用 isDownlinkPaused 判断(而非 isReconnecting):这样即便回退到后台扫描回连成功也能恢复,不会永久卡暂停。 + // 延时略晚于上面的唤醒词版本查询(1500ms),确保通知通道已就绪能收到 0xBB 响应。 + if (isDownlinkPaused) { + CallLog.i(TAG, "[CALL_RECONNECT] 服务发现完成,1600ms 后重下 A2DP_PLAY 并恢复下行") + Handler(Looper.getMainLooper()).postDelayed({ + rearmCallTranslationAfterReconnect() + }, 1600) + } } override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { @@ -1044,14 +1128,16 @@ object BleService { when (c.uuid) { // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { - + // 上行收包长度保护:非 40 倍数的异常包直接丢弃(不计入统计、不喂解码器) + if (!isValidUplinkAudioLen(data)) return // 统计接收到的字节数 bytesReceivedInCurrentSecond += data.size processAudioData(data) // Log.i(TAG, "lxm--收到音频特征数据, 字节数=${data.size}") } BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID -> { - + // 上行收包长度保护:非 40 倍数的异常包直接丢弃(不计入统计、不喂解码器) + if (!isValidUplinkAudioLen(data)) return // 统计接收到的字节数 bytesReceivedInCurrentSecond += data.size processAudioData(data) @@ -1332,6 +1418,18 @@ object BleService { } } + /** + * 上行音频收包长度保护:opus 帧长为 40B(立体声一帧=80B=2×40B),合法包应是 40 的整数倍且非空。 + * 非 40 整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、 + * 且不计入接收统计、不喂解码器;避免坏包喂进定长流式解码器把后续整段字节流搞错位、全部解错。 + * @return true=长度合法可继续处理;false=异常包(已打印告警),应丢弃 + */ + private fun isValidUplinkAudioLen(data: ByteArray): Boolean { + if (data.isNotEmpty() && data.size % 40 == 0) return true + CallLog.w(TAG, "上行收到异常长度音频包,已丢弃: size=${data.size}B (非 opus 帧长 40B 整数倍)") + return false + } + /** * 处理接收到的音频数据 * @param data 音频数据 (Opus编码) @@ -1409,6 +1507,7 @@ object BleService { */ private fun addAudioDataToSendQueue(data: ByteArray) { if (data.isEmpty()) return + // 重连窗口内不丢弃:继续入队,由发送线程在暂停期间抽干到左右声道缓存暂存,回连成功后按节拍继续下发。 if (!audioSendQueue.offer(data)) { Log.w(TAG, "音频发送队列已满,丢弃当前包 size=${data.size}") } @@ -1426,7 +1525,7 @@ object BleService { isAudioSending.set(true) audioSendThread = Thread { - Log.i(TAG, "音频发送线程已启动") + CallLog.i(TAG, "音频发送线程已启动") // 固定 40ms 一拍的发送节拍。每一拍做一次"发不发/发哪路"的决策;左右各按自身档位(paceTicks) // 控制频率(40/80/160ms),定时器始终稳定在 audioSendIntervalNormal 一拍,下发间隔不随 poll/发送耗时抖动。 @@ -1435,6 +1534,21 @@ object BleService { while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { try { + // 0) 重连窗口:暂停下发但**不丢译音**——仍抽干主队列到左右声道缓存暂存 + // (缓存超大兜底 CHANNEL_HOLD_BUFFER_MAX 才丢最旧防 OOM),只是本拍不写 BLE; + // 待回连成功恢复后按节拍把暂存的译音继续发出。 + if (isDownlinkPaused) { + while (true) { + val more = audioSendQueue.poll() ?: break + routeToHoldBuffer(more) + } + nextTickAt += audioSendIntervalNormal + tick++ + val sleepMs = nextTickAt - System.currentTimeMillis() + if (sleepMs > 0) Thread.sleep(sleepMs) else nextTickAt = System.currentTimeMillis() + continue + } + // 1) 非阻塞抽干主队列,按声道分流到左右缓存(不在热路径阻塞,保证节拍稳定)。 // 合包布局:[4B 序号(小端)] + [1B 声道(0=左/1=右)] + [N × opus] while (true) { @@ -1514,18 +1628,24 @@ object BleService { buffer.addFirst(audioChunk) writeFailRetryCount++ if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { - Log.w(TAG, "下行写入拥塞,重试中 ch=$chTag seq=$seq 连续失败=$writeFailRetryCount " + + CallLog.w(TAG, "下行写入拥塞,重试中 ch=$chTag seq=$seq 连续失败=$writeFailRetryCount " + "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size})") } return false } if (writeFailRetryCount > 0) { - Log.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") + CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") writeFailRetryCount = 0 } - // 调试录音文件只保留拼接后的纯 opus 数据,剥离 5 字节包头再写入(仅成功时写,避免重试重复) + // 调试录音文件按声道分别保存(左右各自独立编码,混写无法解码听辨): + // 剥离 5 字节包头后,按声道标记写入对应声道文件(仅成功时写,避免重试重复) if (audioChunk.size > 5) { - recordfile1?.saveAudioDataToWav(audioChunk.copyOfRange(5, audioChunk.size)) + val opusPayload = audioChunk.copyOfRange(5, audioChunk.size) + when (channelByte) { + OpusAudioManager.CHANNEL_LEFT -> recordfile1Left?.saveAudioDataToWav(opusPayload) + OpusAudioManager.CHANNEL_RIGHT -> recordfile1Right?.saveAudioDataToWav(opusPayload) + else -> {} + } } // 发送成功后才推进发送节拍统计与声道包序 if (audioChunk.size >= 5) { @@ -1547,15 +1667,17 @@ object BleService { else -> 0L } val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" - Log.i(TAG, "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms size=${audioChunk.size}B " + + val sendLogLine = "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms size=${audioChunk.size}B " + "lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag pace(L=${leftPaceTicks}x,R=${rightPaceTicks}x) " + - "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}") + "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}" + // 每包都落文件:推送节拍(Δ)、档位(pace)、背压(hold/queue)、丢包(gap) 是控流排查的核心信号,需完整记录 + CallLog.i(TAG, sendLogLine) // [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE 耳机(每句首包)。 // 与 azure_speech 的 [LAT-TRACE] 点2(app收到译音) 同时钟相减 = app 内部下行耗时。 if (channelByte == OpusAudioManager.CHANNEL_RIGHT) { if (now - latLastBleRightTs > 700) { - Log.i(TAG, "[LAT-TRACE] 3.译音写到BLE(右/对方) ts=$now seq=$seq") + CallLog.i(TAG, "[LAT-TRACE] 3.译音写到BLE(右/对方) ts=$now seq=$seq") } latLastBleRightTs = now } @@ -1576,7 +1698,7 @@ object BleService { } if (buffer.size >= CHANNEL_HOLD_BUFFER_MAX) { buffer.removeFirst() - Log.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧包 ch=${channelTag}") + CallLog.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧包 ch=${channelTag}") } buffer.addLast(chunk) } @@ -1587,6 +1709,15 @@ object BleService { private fun stopAudioSendThread() { isAudioSending.set(false) audioSendThread?.interrupt() + resetDownlinkBuffers() + CallLog.i(TAG, "音频发送线程已停止,队列和缓冲区已清空") + } + + /** + * 清空下行发送队列/左右声道缓存,并复位档位、包序、节拍与流控计数。 + * 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。 + */ + private fun resetDownlinkBuffers() { audioSendQueue.clear() audioBuffer.clear() // 清空音频缓冲区 leftHoldBuffer.clear() // 清空左声道下行缓存 @@ -1601,7 +1732,106 @@ object BleService { lastRightSeq = -1L writeFailRetryCount = 0 // 重置写入拥塞计数 latLastBleRightTs = 0L // 重置延迟追踪计时 - Log.i(TAG, "音频发送线程已停止,队列和缓冲区已清空") + } + + // ====================================================================================================== + // 通话翻译:断连自动回连 + 下行暂停/恢复 + // ====================================================================================================== + + /** + * 进入下行暂停(重连窗口):仅置暂停标志,**不清空缓存**。 + * 发送线程随即停止下发,但仍把主队列译音抽干暂存到左右声道缓存;回连成功后原样续发,做到"断连不丢译音"。 + */ + private fun enterDownlinkPause() { + if (isDownlinkPaused) return + isDownlinkPaused = true + CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已暂停(重连中),译音继续缓存待回连后续发 " + + "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}") + } + + /** + * 退出下行暂停:保留暂停期间缓存的译音,仅复位下发档位/发送拍号为全速, + * 让积压的译音尽快开始下发(设备刚回连解码缓存是空的),之后仍由 F4/F5 反馈动态调速防溢出。 + */ + private fun exitDownlinkPause() { + if (!isDownlinkPaused) return + // 复位节拍/档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速 + leftPaceTicks = 1 + rightPaceTicks = 1 + lastLeftSendTick = -1L + lastRightSendTick = -1L + holdDrainPreferLeft = true + lastAudioSendTime = -1L + isDownlinkPaused = false + CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " + + "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}") + } + + /** + * 通话翻译断连后,安排一次延迟主动回连:先暂停下行,1s 后按已配对 MAC 回连。 + * 每次断连(含回连失败再次断连)都会走到这里,据 reconnectAttempts 递增/限次;超上限回退后台扫描。 + */ + private fun scheduleCallTranslationReconnect() { + // 立即暂停下发(译音继续缓存不丢弃) + enterDownlinkPause() + + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + CallLog.w(TAG, "[CALL_RECONNECT] 主动回连已达上限($MAX_RECONNECT_ATTEMPTS 次),回退后台扫描回连") + reconnectAttempts = 0 + isReconnecting = false + val pairedUuid = companionManager?.getPairedUuid() + val pairedMac = companionManager?.getPairedMacAddress() + companionManager?.registerBackgroundScan(pairedMac, pairedUuid) + return + } + + isReconnecting = true + reconnectAttempts++ + reconnectHandler.removeCallbacks(reconnectRunnable) + CallLog.i(TAG, "[CALL_RECONNECT] ${RECONNECT_DELAY_MS}ms 后发起第 $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS 次主动回连") + reconnectHandler.postDelayed(reconnectRunnable, RECONNECT_DELAY_MS) + } + + /** + * 取消进行中的通话翻译自动回连并复位相关状态、恢复下行暂停。 + * 用于主动断开(disconnect)与翻译正常关闭(closeCodec)。 + */ + private fun cancelCallTranslationReconnect() { + // 仅在确有回连/暂停在进行时打日志,避免正常关闭时冗余刷屏 + if (isReconnecting || isDownlinkPaused) { + CallLog.i(TAG, "[CALL_RECONNECT] 取消自动回连(主动断开/翻译关闭),已发起 $reconnectAttempts 次") + } + reconnectHandler.removeCallbacks(reconnectRunnable) + isReconnecting = false + reconnectAttempts = 0 + exitDownlinkPause() + } + + /** + * 回连成功(服务发现完成)后重进翻译态:保守方案——重下 A2DP_PLAY 让设备重新进入翻译播放, + * 但**不重启**已在运行的 Opus 编解码线程(避免重置流状态),随后恢复下行音频。 + */ + private fun rearmCallTranslationAfterReconnect() { + // 期间又断开 → 交由新一轮 STATE_DISCONNECTED 处理,保留重试计数不复位 + if (connectionState.value != BleConst.STATE_CONNECTED) { + CallLog.w(TAG, "[CALL_RECONNECT] 重进翻译态时发现已非连接态,放弃(等待下一轮回连)") + return + } + // 翻译已结束(用户/设备关闭) → 不再重进翻译态,清理回连状态 + if (!isCallTranslating) { + CallLog.i(TAG, "[CALL_RECONNECT] 回连后通话翻译已结束,跳过重进翻译态") + cancelCallTranslationReconnect() + return + } + val cmdData = byteArrayOf( + BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), + BleConst.AUDIO_CHANNEL_STEREO.toByte() + ) + val ok = commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData) + CallLog.i(TAG, "[CALL_RECONNECT] 回连成功,重下 A2DP_PLAY 指令 result=$ok,恢复下行音频(设备重进翻译播放态)") + exitDownlinkPause() + isReconnecting = false + reconnectAttempts = 0 } /** @@ -1765,11 +1995,20 @@ object BleService { // ====================================================================================================== /** - * 设置通话翻译调试参数(运行时生效,无需重连)。 - * @param sendIntervalMs 音频下行发送间隔(ms),null 或 <=0 表示不修改 - * @param bundleFrameCount 下行合包帧数,null 或 <=0 表示不修改 - */ - fun setCallTranslationDebugParams(sendIntervalMs: Long?, bundleFrameCount: Int?) { + * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。 + * @param sendIntervalMs 音频下行发送间隔(ms) + * @param bundleFrameCount 下行合包帧数 + * @param t1FreeBytes/t1SendMs 第一档(最快):空余下限 / 发送间隔(ms) + * @param t2FreeBytes/t2SendMs 第二档(中速):空余下限 / 发送间隔(ms) + * @param t3FreeBytes/t3SendMs 第三档(最慢):空余下限(兜底) / 发送间隔(ms) + */ + fun setCallTranslationDebugParams( + sendIntervalMs: Long?, + bundleFrameCount: Int?, + t1FreeBytes: Int? = null, t1SendMs: Long? = null, + t2FreeBytes: Int? = null, t2SendMs: Long? = null, + t3FreeBytes: Int? = null, t3SendMs: Long? = null + ) { if (sendIntervalMs != null && sendIntervalMs > 0) { audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") @@ -1778,16 +2017,36 @@ object BleService { opusAudioManager.setBundleFrameCount(bundleFrameCount) Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${opusAudioManager.getBundleFrameCount()}") } + // 三档控流:门限允许 0(第三档兜底),故用 >=0 判定;发送间隔要求 >0 + if (t1FreeBytes != null && t1FreeBytes >= 0) tier1FreeBytes = t1FreeBytes + if (t1SendMs != null && t1SendMs > 0) tier1SendMs = t1SendMs.coerceIn(1L, 2000L) + if (t2FreeBytes != null && t2FreeBytes >= 0) tier2FreeBytes = t2FreeBytes + if (t2SendMs != null && t2SendMs > 0) tier2SendMs = t2SendMs.coerceIn(1L, 2000L) + if (t3FreeBytes != null && t3FreeBytes >= 0) tier3FreeBytes = t3FreeBytes + if (t3SendMs != null && t3SendMs > 0) tier3SendMs = t3SendMs.coerceIn(1L, 2000L) + if (t1FreeBytes != null || t1SendMs != null || t2FreeBytes != null || + t2SendMs != null || t3FreeBytes != null || t3SendMs != null) { + Log.i(TAG, "[CALL_TRANS_DEBUG] 设置三档控流: " + + "档1(≥${tier1FreeBytes}B→${tier1SendMs}ms) " + + "档2(≥${tier2FreeBytes}B→${tier2SendMs}ms) " + + "档3(其余→${tier3SendMs}ms)") + } } /** * 获取当前通话翻译调试参数。 - * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) + * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) / 三档门限与间隔 */ fun getCallTranslationDebugParams(): Map { return mapOf( "sendIntervalMs" to audioSendIntervalNormal, - "bundleFrameCount" to opusAudioManager.getBundleFrameCount() + "bundleFrameCount" to opusAudioManager.getBundleFrameCount(), + "tier1FreeBytes" to tier1FreeBytes, + "tier1SendMs" to tier1SendMs, + "tier2FreeBytes" to tier2FreeBytes, + "tier2SendMs" to tier2SendMs, + "tier3FreeBytes" to tier3FreeBytes, + "tier3SendMs" to tier3SendMs ) } @@ -1836,9 +2095,13 @@ object BleService { * @return 是否成功发送命令 */ fun closeCodec(): Boolean { - Log.i(TAG, "关闭编解码...") + CallLog.i(TAG, "关闭编解码...") + // 结束通话翻译会话:取消任何进行中的自动回连并复位暂停状态,避免关闭后又被自动连回 + isCallTranslating = false + cancelCallTranslationReconnect() recordfile?.closeFile() - recordfile1?.closeFile() + recordfile1Left?.closeFile() + recordfile1Right?.closeFile() val cmdData = byteArrayOf( BleConst.CODEC_CONTROL_CLOSE.toByte(), BleConst.AUDIO_CHANNEL_STEREO.toByte() @@ -1868,16 +2131,24 @@ object BleService { * @return 是否成功发送命令 */ fun openA2DPDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { - Log.i(TAG, "[CALL_TRANS_CMD] 打开A2DP解码器(通话翻译) channelMode=$channelMode") + CallLog.i(TAG, "[CALL_TRANS_CMD] 打开A2DP解码器(通话翻译) channelMode=$channelMode") + // 进入通话翻译会话:允许意外断连时自动回连;复位主动断开/暂停/回连计数等状态 + isCallTranslating = true + isManualDisconnect = false + isDownlinkPaused = false + isReconnecting = false + reconnectAttempts = 0 + reconnectHandler.removeCallbacks(reconnectRunnable) startOpusEncodeStream() - recordfile1?.creatingFiles(); + recordfile1Left?.creatingFiles(); + recordfile1Right?.creatingFiles(); startOpusStreamDecoding(false, 2, 16000, 80) val cmdData = byteArrayOf( BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), channelMode.toByte() ) val ok = commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData) - Log.i(TAG, "[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=$ok bytes=${cmdData.joinToString(",") { "0x%02X".format(it) }}") + CallLog.i(TAG, "[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=$ok bytes=${cmdData.joinToString(",") { "0x%02X".format(it) }}") return ok } @@ -2023,6 +2294,14 @@ object BleService { return connectionState.value == BleConst.STATE_CONNECTED } + /** + * 是否正处于通话翻译会话中。 + * 供 BleAgent 等外部组件在 BLE 断连时判断:若为 true,则不要停掉语音服务(closeCodec), + * 由 BleService 内部的自动回连(暂停下行→1s回连→重进翻译态)接管,避免拆掉会话导致无法恢复。 + * openA2DPDecoder() 置 true、closeCodec() 置 false,故它准确反映"当前是否在通话翻译"。 + */ + fun isInCallTranslation(): Boolean = isCallTranslating + /** * 获取缓存设备地址 * @return mac地址 @@ -2122,7 +2401,8 @@ object BleService { // 清理录音文件 recordfile = null - recordfile1 = null + recordfile1Left = null + recordfile1Right = null // 停止音频处理 stopAudioSendThread() stopOpusStreamDecoding() @@ -2204,16 +2484,16 @@ object BleService { private fun onDecodeFreeReported(channel: Int, freeBytes: Int) { when (channel) { BleConst.AUDIO_CHANNEL_LEFT -> { - val pace = nextPaceTicks(leftPaceTicks, freeBytes) + val pace = nextPaceTicks(freeBytes) if (leftPaceTicks != pace) { - Log.i(TAG, "左声道解码空余=$freeBytes 字节,下发档位 ${leftPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") + CallLog.i(TAG, "左声道解码空余=$freeBytes 字节,下发档位 ${leftPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") } leftPaceTicks = pace } BleConst.AUDIO_CHANNEL_RIGHT -> { - val pace = nextPaceTicks(rightPaceTicks, freeBytes) + val pace = nextPaceTicks(freeBytes) if (rightPaceTicks != pace) { - Log.i(TAG, "右声道解码空余=$freeBytes 字节,下发档位 ${rightPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") + CallLog.i(TAG, "右声道解码空余=$freeBytes 字节,下发档位 ${rightPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") } rightPaceTicks = pace } @@ -2222,17 +2502,19 @@ object BleService { } /** - * 带迟滞的三档流控:由当前档位 currentPace 与最新空余 freeBytes 决定新档位(拍)。三档=拍数 1/4/8 → 40/160/320ms/包。 - * 降档敏感、升档保守,中间区按当前档位保持,避免边界反复横跳: - * 空余 ≤CRIT_DOWN → 8(320ms);≥NORMAL_UP → 1(40ms); - * 中间区:40ms 档跌破 NORMAL_DOWN 才降 160ms;320ms 档升过 CRIT_UP 才回 160ms;160ms 档维持。 + * 简单三档流控(无迟滞):按最新空余 freeBytes 从第一档往下取第一个满足 freeBytes>=门限 的档, + * 得到该档发送间隔 sendMs,再按当前拍长 audioSendIntervalNormal 换算为拍数(paceTicks)。 + * 三档门限/间隔均可在通话调试界面运行时调整;发送线程按 paceTicks 控制该声道下发频率。 + * @return 拍数(≥1),即"每 paceTicks 拍发一包"。 */ - private fun nextPaceTicks(currentPace: Int, freeBytes: Int): Int = when { - freeBytes <= DECODE_FREE_CRIT_DOWN -> 8 - freeBytes >= DECODE_FREE_NORMAL_UP -> 1 - currentPace == 8 -> if (freeBytes >= DECODE_FREE_CRIT_UP) 4 else 8 - currentPace == 1 -> if (freeBytes <= DECODE_FREE_NORMAL_DOWN) 4 else 1 - else -> 4 + private fun nextPaceTicks(freeBytes: Int): Int { + val sendMs = when { + freeBytes >= tier1FreeBytes -> tier1SendMs + freeBytes >= tier2FreeBytes -> tier2SendMs + else -> tier3SendMs + } + // ms → 拍数:四舍五入到最近的整数拍,至少 1 拍(不快于全速拍长) + return Math.round(sendMs.toDouble() / audioSendIntervalNormal).toInt().coerceAtLeast(1) } /** diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 8c534d2dc..63f981875 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -34,6 +34,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, // 事件通道 - 只保留数据和状态通道 private var dataEventChannel: EventChannel? = null private var statusEventChannel: EventChannel? = null + private var callLogEventChannel: EventChannel? = null // 应用上下文 private lateinit var context: Context @@ -98,6 +99,19 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, } }) } + + // 通话翻译日志上报通道:native CallLog 的日志经此发往 Dart,由 Dart Logger 统一写入 app_*.log(单一日志文件) + callLogEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/call_log_events").apply { + setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + CallLog.setEmitter { line -> mainHandler.post { try { events?.success(line) } catch (_: Exception) {} } } + } + + override fun onCancel(arguments: Any?) { + CallLog.setEmitter(null) + } + }) + } } // 实现ActivityAware接口 @@ -171,6 +185,8 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, // 解除事件通道 dataEventChannel?.setStreamHandler(null) statusEventChannel?.setStreamHandler(null) + callLogEventChannel?.setStreamHandler(null) + CallLog.setEmitter(null) } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { @@ -233,7 +249,16 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, "setCallTranslationDebugParams" -> { val sendIntervalMs = (call.argument("sendIntervalMs"))?.toLong() val bundleFrameCount = (call.argument("bundleFrameCount"))?.toInt() - BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount) + val t1FreeBytes = (call.argument("tier1FreeBytes"))?.toInt() + val t1SendMs = (call.argument("tier1SendMs"))?.toLong() + val t2FreeBytes = (call.argument("tier2FreeBytes"))?.toInt() + val t2SendMs = (call.argument("tier2SendMs"))?.toLong() + val t3FreeBytes = (call.argument("tier3FreeBytes"))?.toInt() + val t3SendMs = (call.argument("tier3SendMs"))?.toLong() + BleService.setCallTranslationDebugParams( + sendIntervalMs, bundleFrameCount, + t1FreeBytes, t1SendMs, t2FreeBytes, t2SendMs, t3FreeBytes, t3SendMs + ) result.success(true) } "getCallTranslationDebugParams" -> { diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/CallLog.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/CallLog.kt new file mode 100644 index 000000000..91d9111ee --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/CallLog.kt @@ -0,0 +1,73 @@ +package com.yunqiinnovation.ble_service + +import android.util.Log +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 通话翻译日志(ble_service 侧:下行控流/发送节拍/指令)。 + * + * 不自己写文件,而是把预格式化日志行**批量上报给 Dart**,由 Dart `Logger` 统一写入唯一的 `app_*.log`。 + * - **毫秒时间戳在产生点(native)打**,与 Dart 日志同格式同时钟 `yyyy-MM-dd HH:mm:ss.SSS [级别][tag] 消息`; + * - **批量上报**:日志只入内存队列,由后台线程每 [FLUSH_INTERVAL_MS] 把整批拼成一条 payload 上报一次, + * 避免高频日志(音频每包/F4-F5 每秒几十条)每条都 `mainHandler.post` 到主线程把主线程压垮导致卡顿; + * - emitter 未就绪前照常入队缓存,就绪后由 flusher 一并发出;保留 logcat。 + */ +object CallLog { + private const val TAG = "CallLog" + private const val MAX_QUEUE = 8000 // 队列上限(防 OOM),超限丢最旧 + private const val FLUSH_INTERVAL_MS = 200L // 批量上报间隔:把这段时间内的日志攒成一批发一次 + + private val tsFmt = ThreadLocal.withInitial { SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) } + + private val buffer = LinkedBlockingQueue() + @Volatile private var emitter: ((String) -> Unit)? = null + private val started = AtomicBoolean(false) + + /** 由插件在日志 EventChannel onListen/onCancel 时调用:设置/解绑「把整批日志发往 Dart」的回调。 */ + fun setEmitter(e: ((String) -> Unit)?) { + emitter = e + if (e != null) startFlusher() + } + + fun d(tag: String, message: String) { Log.d(tag, message); enqueue("DEBUG", tag, message) } + fun i(tag: String, message: String) { Log.i(tag, message); enqueue("INFO", tag, message) } + fun w(tag: String, message: String) { Log.w(tag, message); enqueue("WARN", tag, message) } + fun e(tag: String, message: String) { Log.e(tag, message); enqueue("ERROR", tag, message) } + + private fun enqueue(level: String, tag: String, msg: String) { + // 时间戳在产生点打(即使批量上报稍晚),保证排查精度 + val line = "${tsFmt.get().format(Date())} [$level][$tag] $msg" + while (buffer.size >= MAX_QUEUE) buffer.poll() + buffer.offer(line) + } + + private fun startFlusher() { + if (started.getAndSet(true)) return + Thread({ + while (true) { + try { + Thread.sleep(FLUSH_INTERVAL_MS) + flush() + } catch (e: InterruptedException) { + break + } catch (e: Exception) { + Log.e(TAG, "CallLog flush 异常: ${e.message}") + } + } + }, "CallLogFlusher").apply { isDaemon = true; start() } + } + + /** 把队列里累积的所有行拼成一条 payload(\n 分隔),一次性上报给 Dart,主线程只 post 一次。 */ + private fun flush() { + val e = emitter ?: return + if (buffer.isEmpty()) return + val batch = ArrayList(buffer.size) + buffer.drainTo(batch) + if (batch.isEmpty()) return + try { e(batch.joinToString("\n")) } catch (_: Exception) {} + } +} 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 fa0c639bc..bd1c1e30c 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 @@ -44,7 +44,7 @@ class OpusAudioManager { // 下行合包默认帧数:左右各自累积 bundleFrameCount 帧 opus 合并为 1 包后才下发,降低 BLE 发送频率 // 调试界面可在运行时调整;通过 setBundleFrameCount() 修改 - const val DEFAULT_BUNDLE_FRAME_COUNT = 4 + const val DEFAULT_BUNDLE_FRAME_COUNT = 5 @Volatile private var bundleFrameCount = DEFAULT_BUNDLE_FRAME_COUNT // 合包头部:bytes 0-3 序号(小端 uint32),byte 4 声道标识(0=左/1=右) @@ -277,7 +277,7 @@ class OpusAudioManager { setSampleRate(sampleRate) setPacketSize(packetSize) } - + Log.d(TAG, "准备开始Opus数据流解码, 参数: hasHeader=$hasHeader, channel=$channel, sampleRate=$sampleRate, packetSize=$packetSize") try { @@ -325,7 +325,7 @@ class OpusAudioManager { fun stopDecodeStream(): Boolean { if (decodeOpusManager?.isDecodeStream == true) { decodeOpusManager?.stopDecodeStream() - + // 清理音频数据缓存 synchronized(audioDataBuffer) { audioDataBuffer.clear() @@ -484,7 +484,7 @@ class OpusAudioManager { override fun onStart() { Log.i(TAG, "左声道独立编码已开始") } override fun onComplete(outPath: String?) {} override fun onError(code: Int, message: String?) { - Log.e(TAG, "左声道编码错误: [$code] $message") + CallLog.e(TAG, "左声道编码错误: [$code] $message") callback?.onError("encode", code, message) } }) @@ -495,14 +495,14 @@ class OpusAudioManager { override fun onStart() { Log.i(TAG, "右声道独立编码已开始") } override fun onComplete(outPath: String?) {} override fun onError(code: Int, message: String?) { - Log.e(TAG, "右声道编码错误: [$code] $message") + CallLog.e(TAG, "右声道编码错误: [$code] $message") callback?.onError("encode", code, message) } }) isDualEncoding = true callback?.onEncodeStreamStateChanged(true) - Log.i(TAG, "双声道独立编码流已启动 sampleRate=$sampleRate packetSize=$packetSize") + CallLog.i(TAG, "双声道独立编码流已启动 sampleRate=$sampleRate packetSize=$packetSize") true } catch (e: Exception) { Log.e(TAG, "启动双声道编码流失败: ${e.message}", e) @@ -527,7 +527,7 @@ class OpusAudioManager { } closePcmWavWriters() callback?.onEncodeStreamStateChanged(false) - Log.i(TAG, "双声道独立编码流已停止") + CallLog.i(TAG, "双声道独立编码流已停止") return true } diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index 2fbe25f60..ae77aec7a 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -175,15 +175,30 @@ class BleService { /// 设置通话翻译调试参数(运行时生效) /// [sendIntervalMs] 音频下行发送间隔(ms) /// [bundleFrameCount] 下行合包帧数 + /// [tier1FreeBytes]/[tier1SendMs] 第一档(最快):空余下限 / 发送间隔(ms) + /// [tier2FreeBytes]/[tier2SendMs] 第二档(中速):空余下限 / 发送间隔(ms) + /// [tier3FreeBytes]/[tier3SendMs] 第三档(最慢):空余下限(兜底) / 发送间隔(ms) Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, + int? tier1FreeBytes, + int? tier1SendMs, + int? tier2FreeBytes, + int? tier2SendMs, + int? tier3FreeBytes, + int? tier3SendMs, }) async { try { final result = await _methodChannel .invokeMethod('setCallTranslationDebugParams', { if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs, if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount, + if (tier1FreeBytes != null) 'tier1FreeBytes': tier1FreeBytes, + if (tier1SendMs != null) 'tier1SendMs': tier1SendMs, + if (tier2FreeBytes != null) 'tier2FreeBytes': tier2FreeBytes, + if (tier2SendMs != null) 'tier2SendMs': tier2SendMs, + if (tier3FreeBytes != null) 'tier3FreeBytes': tier3FreeBytes, + if (tier3SendMs != null) 'tier3SendMs': tier3SendMs, }); return result ?? false; } catch (e) { @@ -193,15 +208,23 @@ class BleService { } /// 获取当前通话翻译调试参数 - /// 返回 {'sendIntervalMs': int, 'bundleFrameCount': int},失败返回空 Map + /// 返回 {'sendIntervalMs','bundleFrameCount','tier1FreeBytes','tier1SendMs', + /// 'tier2FreeBytes','tier2SendMs','tier3FreeBytes','tier3SendMs'},失败返回空 Map Future> getCallTranslationDebugParams() async { try { final result = await _methodChannel .invokeMethod('getCallTranslationDebugParams'); if (result == null) return {}; + int readInt(String k) => (result[k] as num?)?.toInt() ?? 0; return { - 'sendIntervalMs': (result['sendIntervalMs'] as num?)?.toInt() ?? 0, - 'bundleFrameCount': (result['bundleFrameCount'] as num?)?.toInt() ?? 0, + 'sendIntervalMs': readInt('sendIntervalMs'), + 'bundleFrameCount': readInt('bundleFrameCount'), + 'tier1FreeBytes': readInt('tier1FreeBytes'), + 'tier1SendMs': readInt('tier1SendMs'), + 'tier2FreeBytes': readInt('tier2FreeBytes'), + 'tier2SendMs': readInt('tier2SendMs'), + 'tier3FreeBytes': readInt('tier3FreeBytes'), + 'tier3SendMs': readInt('tier3SendMs'), }; } catch (e) { print('获取通话翻译调试参数失败: $e');