Browse Source

做一下 标记

newdev_chengguofeng
liwei1dao 3 months ago
parent
commit
cb76b101e8
  1. 347
      docs/通话翻译-流程与控流协议.md
  2. 30
      lib/core/utils/logger.dart
  3. 21
      lib/data/services/ble_manager.dart
  4. 4
      lib/data/services/speech_impl/azure_ast_service.dart
  5. 12
      lib/main.dart
  6. 104
      lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart
  7. 100
      lib/modules/call_translation_debug/views/call_translation_debug_view.dart
  8. 20
      lib/modules/log_export/controllers/log_export_controller.dart
  9. 6
      lib/modules/log_export/views/log_export_view.dart
  10. 95
      lib/modules/opus_test/controllers/opus_test_controller.dart
  11. 18
      lib/modules/opus_test/views/opus_test_view.dart
  12. 7
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt
  13. 39
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt
  14. 17
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  15. 72
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallLog.kt
  16. 6
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt
  17. 384
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  18. 27
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  19. 73
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/CallLog.kt
  20. 10
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt
  21. 29
      local_plugins/ble_service/lib/ble_service.dart

347
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 通道与指令协议,但不属于通话翻译数据流,本文不展开。

30
lib/core/utils/logger.dart

@ -39,14 +39,17 @@ class Logger {
static String? _logDirPath; static String? _logDirPath;
static final DateFormat _fileDateFormat = DateFormat('yyyy-MM-dd'); static final DateFormat _fileDateFormat = DateFormat('yyyy-MM-dd');
/// 日志文件名时间戳(精确到秒):每次启动/跨天各一个独立文件,方便在导出列表里区分不同运行
static final DateFormat _fileStampFormat = DateFormat('yyyy-MM-dd_HHmmss');
/// 单行最大字符数,超过则截断(避免日志被过长字符串拖慢) /// 单行最大字符数,超过则截断(避免日志被过长字符串拖慢)
static const int _maxLineLength = 4000; static const int _maxLineLength = 4000;
/// 最大单个日志文件大小 (2MB) /// 最大单个日志文件大小 (2MB)
static const int _maxFileSize = 2 * 1024 * 1024; static const int _maxFileSize = 2 * 1024 * 1024;
/// 最多保留日志文件数量 /// 最多保留日志文件数量(文件名精确到秒后每次启动一个文件,适当调大以免频繁启动把当天日志冲掉)
static const int _maxLogFiles = 7; static const int _maxLogFiles = 20;
/// 内存队列:写日志只入队,由定时器批量落盘 /// 内存队列:写日志只入队,由定时器批量落盘
static final List<String> _queue = <String>[]; static final List<String> _queue = <String>[];
@ -100,8 +103,10 @@ class Logger {
/// 打开/创建当前日志文件 /// 打开/创建当前日志文件
static Future<void> _openLogFile() async { static Future<void> _openLogFile() async {
final today = _fileDateFormat.format(DateTime.now()); final now = DateTime.now();
final filePath = '$_logDirPath/app_$today.log'; final today = _fileDateFormat.format(now);
// 文件名精确到秒:每次启动/跨天都是独立文件,导出列表里可按运行时段区分
final filePath = '$_logDirPath/app_${_fileStampFormat.format(now)}.log';
_currentLogFile = File(filePath); _currentLogFile = File(filePath);
await _logSink?.flush(); await _logSink?.flush();
await _logSink?.close(); await _logSink?.close();
@ -218,6 +223,23 @@ class Logger {
_queue.add('$ts [$level][$tag] $msg'); _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; static String? get logDirPath => _logDirPath;

21
lib/data/services/ble_manager.dart

@ -384,6 +384,14 @@ class BleManager extends GetxService {
_updateBatteryInfoFromData(data); _updateBatteryInfoFromData(data);
break; break;
case BleCommands.CONTROL_CODEC: 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); final bool codecSuccess = _isCodecControlSuccess(data);
if (_awaitingCodecOpen) { if (_awaitingCodecOpen) {
_isCodecActive.value = codecSuccess; _isCodecActive.value = codecSuccess;
@ -1249,14 +1257,27 @@ class BleManager extends GetxService {
/// 设置通话翻译调试参数(运行时生效) /// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数 /// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数
/// tierN* 为三档控流:每档 空余下限(FreeBytes) + 发送间隔(SendMs)
Future<bool> setCallTranslationDebugParams({ Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs, int? sendIntervalMs,
int? bundleFrameCount, int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async { }) async {
try { try {
return await _bleService.setCallTranslationDebugParams( return await _bleService.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs, sendIntervalMs: sendIntervalMs,
bundleFrameCount: bundleFrameCount, bundleFrameCount: bundleFrameCount,
tier1FreeBytes: tier1FreeBytes,
tier1SendMs: tier1SendMs,
tier2FreeBytes: tier2FreeBytes,
tier2SendMs: tier2SendMs,
tier3FreeBytes: tier3FreeBytes,
tier3SendMs: tier3SendMs,
); );
} catch (e) { } catch (e) {
Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}');

4
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 utteranceId = eventMap['utteranceId'] as String? ?? '';
final String text = eventMap['text'] as String? ?? ''; final String text = eventMap['text'] as String? ?? '';
final String detectedLanguage = eventMap['language'] as String? ?? ''; final String detectedLanguage = eventMap['language'] as String? ?? '';
Logger.d('AzureAST', '[STS] 识别中 [$serviceId]: text=$text'); // [STS] 识别中(增量过渡,每字刷屏,已关闭;最终结果见"识别完成")
_eventStreamController?.add(ASTEvent( _eventStreamController?.add(ASTEvent(
type: ASTEventType.intermediateResult, type: ASTEventType.intermediateResult,
serviceId: serviceId, serviceId: serviceId,
@ -137,7 +137,7 @@ class AzureAstService extends GetxService implements AstService {
final String text = eventMap['translatedText'] as String? ?? ''; final String text = eventMap['translatedText'] as String? ?? '';
final String detectedLanguage = final String detectedLanguage =
eventMap['targetLanguage'] as String? ?? ''; eventMap['targetLanguage'] as String? ?? '';
Logger.d('AzureAST', '[STS] 翻译中 [$serviceId]: text=$text'); // [STS] 翻译中(增量过渡,每字刷屏,已关闭;最终结果见"翻译完成")
_eventStreamController?.add(ASTEvent( _eventStreamController?.add(ASTEvent(
type: ASTEventType.translationInterim, type: ASTEventType.translationInterim,
serviceId: serviceId, serviceId: serviceId,

12
lib/main.dart

@ -43,6 +43,18 @@ void main() async {
// 初始化文件日志 // 初始化文件日志
await Logger.initFileLog(); 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()); Get.put(LoginService());

104
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'; import '../../../core/utils/logger.dart';
/// 通话翻译调试控制器 /// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发, /// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。 /// 以及通话翻译编解码的启动/停止。
class CallTranslationDebugController extends GetxController { class CallTranslationDebugController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>(); final BleManager _bleManager = Get.find<BleManager>();
@ -14,10 +14,23 @@ class CallTranslationDebugController extends GetxController {
// 持久化键 // 持久化键
static const String _kSendInterval = 'call_trans_debug_send_interval'; static const String _kSendInterval = 'call_trans_debug_send_interval';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; 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 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; static const int minSendIntervalMs = 20;
@ -25,6 +38,14 @@ class CallTranslationDebugController extends GetxController {
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms
static const int minBundleFrameCount = 1; static const int minBundleFrameCount = 1;
static const int maxBundleFrameCount = 20; 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) /// 音频下行发送间隔(ms)
final RxInt sendIntervalMs = defaultSendIntervalMs.obs; final RxInt sendIntervalMs = defaultSendIntervalMs.obs;
@ -32,6 +53,14 @@ class CallTranslationDebugController extends GetxController {
/// 下行合包帧数 /// 下行合包帧数
final RxInt bundleFrameCount = defaultBundleFrameCount.obs; 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; RxBool get isCodecActive => _bleManager.isCodecActiveRx;
@ -58,14 +87,24 @@ class CallTranslationDebugController extends GetxController {
bundleFrameCount.value = bundleFrameCount.value =
savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); 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<int>(key);
if (v != null) target.value = v.clamp(lo, hi);
} }
/// 用原生当前实际值同步显示(不覆盖用户本地保存的偏好,仅在无本地保存时参考) /// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值)
Future<void> _syncFromNative() async { Future<void> _syncFromNative() async {
try { try {
final params = await _bleManager.getCallTranslationDebugParams(); final params = await _bleManager.getCallTranslationDebugParams();
if (params.isEmpty) return; if (params.isEmpty) return;
// 仅当本地没有保存过时,采用原生返回值
if (_storage.read<int>(_kSendInterval) == null) { if (_storage.read<int>(_kSendInterval) == null) {
final ni = params['sendIntervalMs']; final ni = params['sendIntervalMs'];
if (ni != null && ni > 0) { if (ni != null && ni > 0) {
@ -79,15 +118,33 @@ class CallTranslationDebugController extends GetxController {
nf.clamp(minBundleFrameCount, maxBundleFrameCount); 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) { } catch (e) {
Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e'); Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e');
} }
} }
void _syncTier(Map<String, int> params, String nativeKey, String storeKey,
RxInt target, int lo, int hi) {
// 剩余字节数下限可为 0,故不用 >0 过滤;仅在本地未保存过时采用原生值
if (_storage.read<int>(storeKey) != null) return;
final v = params[nativeKey];
if (v != null) target.value = v.clamp(lo, hi);
}
void setSendInterval(int value) { void setSendInterval(int value) {
// 按 20ms 步长对齐 final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs;
final snapped =
(value / sendIntervalStepMs).round() * sendIntervalStepMs;
sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs); sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs);
} }
@ -96,22 +153,53 @@ class CallTranslationDebugController extends GetxController {
value.clamp(minBundleFrameCount, maxBundleFrameCount); 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() { void resetDefaults() {
sendIntervalMs.value = defaultSendIntervalMs; sendIntervalMs.value = defaultSendIntervalMs;
bundleFrameCount.value = defaultBundleFrameCount; bundleFrameCount.value = defaultBundleFrameCount;
tier1Free.value = defaultTier1Free;
tier1Send.value = defaultTier1Send;
tier2Free.value = defaultTier2Free;
tier2Send.value = defaultTier2Send;
tier3Free.value = defaultTier3Free;
tier3Send.value = defaultTier3Send;
} }
/// 保存并下发参数到原生(运行时立即生效) /// 保存并下发参数到原生(运行时立即生效)
Future<void> applyParams() async { Future<void> applyParams() async {
_storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kSendInterval, sendIntervalMs.value);
_storage.write(_kBundleFrameCount, bundleFrameCount.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( final ok = await _bleManager.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs.value, sendIntervalMs: sendIntervalMs.value,
bundleFrameCount: bundleFrameCount.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', 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( Get.snackbar(
'通话翻译调试', '通话翻译调试',
ok ? '参数已应用' : '参数下发失败(请确认设备已连接)', ok ? '参数已应用' : '参数下发失败(请确认设备已连接)',

100
lib/modules/call_translation_debug/views/call_translation_debug_view.dart

@ -36,6 +36,8 @@ class CallTranslationDebugView
_buildSendIntervalCard(isDark), _buildSendIntervalCard(isDark),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildBundleFrameCard(isDark), _buildBundleFrameCard(isDark),
SizedBox(height: 16.h),
_buildFlowControlCard(isDark),
SizedBox(height: 24.h), SizedBox(height: 24.h),
_buildActionButtons(isDark), _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<int> 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) { Widget _title(bool isDark, String title, String subtitle) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

20
lib/modules/log_export/controllers/log_export_controller.dart

@ -91,6 +91,26 @@ class LogExportController extends GetxController {
} }
} }
/// 删除单个日志文件
Future<void> 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<void> clearAllLogs() async { Future<void> clearAllLogs() async {
try { try {

6
lib/modules/log_export/views/log_export_view.dart

@ -181,6 +181,12 @@ class LogExportView extends GetView<LogExportController> {
color: isDarkMode ? Colors.blue[300] : Colors.blue), color: isDarkMode ? Colors.blue[300] : Colors.blue),
onPressed: () => controller.shareSingleLog(logFile), 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),
),
], ],
), ),
), ),

95
lib/modules/opus_test/controllers/opus_test_controller.dart

@ -206,8 +206,25 @@ class OpusTestController extends GetxController {
void selectExternalFile(PlatformFile file) { void selectExternalFile(PlatformFile file) {
selectedFiles.clear(); selectedFiles.clear();
selectedFiles.add(file); selectedFiles.add(file);
// 「翻译-*」文件自动套用手机端下行 opus 解码参数,套上后提示里带说明;否则只提示已选择
if (!_applyAutoDecodeParams(file.name)) {
statusMessage.value = '已选择文件: ${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;
}
// 切换通道模式 // 切换通道模式
void toggleChannel(bool isSingleChannel) { void toggleChannel(bool isSingleChannel) {
@ -409,6 +426,84 @@ class OpusTestController extends GetxController {
} }
} }
/// 一键清理:删除当前列表中扫描到的所有文件
Future<void> clearAllFiles() async {
if (externalFiles.isEmpty) {
statusMessage.value = '没有可清理的文件';
return;
}
final total = externalFiles.length;
int success = 0;
int failed = 0;
// 复制一份遍历,避免删除过程中修改原列表
final files = List<PlatformFile>.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<void> shareExternalFile(PlatformFile file) async { Future<void> shareExternalFile(PlatformFile file) async {
try { try {

18
lib/modules/opus_test/views/opus_test_view.dart

@ -109,15 +109,33 @@ class OpusTestView extends GetView<OpusTestController> {
color: isDarkMode ? Colors.white : Colors.black, color: isDarkMode ? Colors.white : Colors.black,
), ),
), ),
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( IconButton(
icon: Icon( icon: Icon(
Icons.refresh, Icons.refresh,
color: isDarkMode ? Colors.blue[300] : Colors.blue, color: isDarkMode ? Colors.blue[300] : Colors.blue,
), ),
tooltip: '刷新',
onPressed: controller.loadExternalFiles, onPressed: controller.loadExternalFiles,
), ),
], ],
), ),
],
),
), ),
// 外部文件列表 // 外部文件列表

7
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt

@ -146,11 +146,18 @@ object BleAgent : BleService.Callback, AgentServiceListener {
if (state == BleConst.STATE_CONNECTED) { if (state == BleConst.STATE_CONNECTED) {
Log.d(TAG, "Ble设备已连接,准备好语音交互") Log.d(TAG, "Ble设备已连接,准备好语音交互")
} else if (state == BleConst.STATE_DISCONNECTED) { } else if (state == BleConst.STATE_DISCONNECTED) {
// 通话翻译进行中的断连:不要在这里 stopListening()(它会 closeCodec 拆掉会话),
// 交给 BleService 内部的自动回连(暂停下行→回连→重进翻译态)接管,否则会话被拆无法恢复。
// 仅当"确实在通话翻译状态"才跳过;其它场景(AI 助手等)保持原有断开即停服。
if (BleService.isInCallTranslation()) {
Log.d(TAG, "Ble设备断开但处于通话翻译中,交由 BleService 自动回连,暂不停止语音服务")
} else {
Log.d(TAG, "Ble设备已断开,停止语音服务") Log.d(TAG, "Ble设备已断开,停止语音服务")
// 设备断开连接,停止语音服务 // 设备断开连接,停止语音服务
stopListening() stopListening()
} }
} }
}
override fun onAudioDataReceived(data: ByteArray, channel: Int) { override fun onAudioDataReceived(data: ByteArray, channel: Int) {
AgentService.pushAudioData(data) AgentService.pushAudioData(data)

39
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 tag = "DoubaoAstCallback"
private val doubaoFinalSourceTextCache: MutableMap<String, String> = mutableMapOf() private val doubaoFinalSourceTextCache: MutableMap<String, String> = mutableMapOf()
// 延迟追踪:每句只在"距上一帧>700ms"(视为新一句)时打一条 [LAT-TRACE],避免逐帧刷屏。 // 延迟追踪:每句(key=sessionId)记录各里程碑时刻,句结束时汇总为一行 [LAT]。
// 同进程 currentTimeMillis 与 BleService 侧可直接相减,定位 app 内部各跳耗时。 // LongArray: [0]=句起点(首个事件) [1]=识别定稿 [2]=首个译文文字 [3]=首个译音。
private var latLastTextTs = 0L private val latMarks = HashMap<String, LongArray>()
private var latLastAudioTs = 0L 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) { override fun onSessionStarted(sessionId: String) {
eventSender.send( eventSender.send(
@ -558,6 +570,7 @@ class DoubaoAstCallback(
override fun onFinalSourceText(sessionId: String, finalText: String) { override fun onFinalSourceText(sessionId: String, finalText: String) {
val key = "$serviceId:$sessionId" val key = "$serviceId:$sessionId"
doubaoFinalSourceTextCache[key] = finalText doubaoFinalSourceTextCache[key] = finalText
latMark(sessionId).let { if (it[1] == 0L) it[1] = System.currentTimeMillis() } // 识别定稿
eventSender.send( eventSender.send(
mapOf( mapOf(
"type" to "recognized", "type" to "recognized",
@ -571,12 +584,7 @@ class DoubaoAstCallback(
} }
override fun onPartialText(sessionId: String, text: String) { override fun onPartialText(sessionId: String, text: String) {
// [LAT-TRACE] 点1:收到译文文字(每句首条,svc=B 即对方) latMark(sessionId).let { if (it[2] == 0L) it[2] = System.currentTimeMillis() } // 首个译文文字
val latNow = System.currentTimeMillis()
if (latNow - latLastTextTs > 700) {
FileLogger.i(tag, "[LAT-TRACE] 1.译文文字到达 svc=$serviceId ts=$latNow text=${text.take(12)}")
}
latLastTextTs = latNow
eventSender.send( eventSender.send(
mapOf( mapOf(
"type" to "translatedInterim", "type" to "translatedInterim",
@ -591,12 +599,7 @@ class DoubaoAstCallback(
} }
override fun onPartialAudio(sessionId: String, data: ByteArray) { override fun onPartialAudio(sessionId: String, data: ByteArray) {
// [LAT-TRACE] 点2:app 收到译音 PCM(每句首帧,svc=B 即对方) latMark(sessionId).let { if (it[3] == 0L) it[3] = System.currentTimeMillis() } // 首个译音
val latNow = System.currentTimeMillis()
if (latNow - latLastAudioTs > 700) {
FileLogger.i(tag, "[LAT-TRACE] 2.app收到译音 svc=$serviceId ts=$latNow size=${data.size}")
}
latLastAudioTs = latNow
audioWriter?.write(data) audioWriter?.write(data)
} }
@ -620,9 +623,12 @@ class DoubaoAstCallback(
// if (finalAudio.isNotEmpty()) { // if (finalAudio.isNotEmpty()) {
// audioWriter?.write(finalAudio) // audioWriter?.write(finalAudio)
// } // }
flushLat(sessionId, finalText) // 句结束:汇总本句延迟为一行
} }
override fun onSessionError(sessionId: String, code: Int, message: String) { override fun onSessionError(sessionId: String, code: Int, message: String) {
CallLog.e(tag, "[AST-ERR] 豆包端到端会话错误 svc=$serviceId code=$code msg=$message")
eventSender.send( eventSender.send(
mapOf( mapOf(
"type" to "error", "type" to "error",
@ -638,6 +644,7 @@ class DoubaoAstCallback(
override fun onFinalTranslatedText(sessionId: String, finalText: String) { override fun onFinalTranslatedText(sessionId: String, finalText: String) {
val key = "$serviceId:$sessionId" val key = "$serviceId:$sessionId"
val original = doubaoFinalSourceTextCache.remove(key) ?: "" val original = doubaoFinalSourceTextCache.remove(key) ?: ""
flushLat(sessionId, finalText) // 句结束:汇总本句延迟为一行(与 onSessionFinished 互斥,remove 保证只打一次)
eventSender.send( eventSender.send(
mapOf( mapOf(
"type" to "translated", "type" to "translated",

17
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 lateinit var astEventChannel: EventChannel
private var astEventSink: EventChannel.EventSink? = null private var astEventSink: EventChannel.EventSink? = null
// 通话翻译日志上报通道:native CallLog 的日志经此发往 Dart,由 Dart Logger 统一写入 app_*.log
private var callLogEventChannel: EventChannel? = null
// 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器 // 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器
private val callBPcmPlayer = CallBPcmPlayer() private val callBPcmPlayer = CallBPcmPlayer()
// 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。
@ -366,6 +369,18 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
astEventSink = null 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 = translationEventChannel =
EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events") EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events")
@ -1844,6 +1859,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
ttsEventChannel.setStreamHandler(null) ttsEventChannel.setStreamHandler(null)
astEventChannel.setStreamHandler(null) astEventChannel.setStreamHandler(null)
translationEventChannel.setStreamHandler(null) translationEventChannel.setStreamHandler(null)
callLogEventChannel?.setStreamHandler(null)
CallLog.setEmitter(null)
try { try {
if (isRegisteredToBle) { if (isRegisteredToBle) {

72
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<String>()
@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<String>(buffer.size)
buffer.drainTo(batch)
if (batch.isEmpty()) return
try { e(batch.joinToString("\n")) } catch (_: Exception) {}
}
}

6
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 BleConst.AUDIO_CHANNEL_LEFT else BleConst.AUDIO_CHANNEL_RIGHT
if (notifyData.size >= 5) { if (notifyData.size >= 5) {
val freeBytes = readInt32BE(notifyData, 1) 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) callback?.notifyCallDecodeFreeReported(channel, freeBytes)
} else { } else {
Log.w(TAG, "解码空余上报数据长度不足: ${notifyData.size}(期望>=5),忽略 code=0x${codecStatus.toString(16)}") Log.w(TAG, "解码空余上报数据长度不足: ${notifyData.size}(期望>=5),忽略 code=0x${codecStatus.toString(16)}")
@ -636,7 +636,7 @@ class BleCommandSender {
BleConst.CMD_CALL_TRANSLATION_ON -> { BleConst.CMD_CALL_TRANSLATION_ON -> {
// 设备上报: 开启通话翻译 0x16 (cmdLen=0x01, status=0xFF) // 设备上报: 开启通话翻译 0x16 (cmdLen=0x01, status=0xFF)
val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 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( val resultMap = mapOf(
"success" to true, "success" to true,
"command" to infoType, "command" to infoType,
@ -649,7 +649,7 @@ class BleCommandSender {
BleConst.CMD_CALL_TRANSLATION_OFF -> { BleConst.CMD_CALL_TRANSLATION_OFF -> {
// 设备上报: 关闭通话翻译 0x17 (cmdLen=0x01, status=0xFF) // 设备上报: 关闭通话翻译 0x17 (cmdLen=0x01, status=0xFF)
val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 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( val resultMap = mapOf(
"success" to true, "success" to true,
"command" to infoType, "command" to infoType,

384
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 callAudioChar: BluetoothGattCharacteristic? = null
private var callWriteChar: BluetoothGattCharacteristic? = null private var callWriteChar: BluetoothGattCharacteristic? = null
var recordfile: RecordingFile? = null var recordfile: RecordingFile? = null
var recordfile1: RecordingFile? = null // 手机端下行 opus 录音按声道分文件:左右各自独立编码,混写到一个文件无法解码听辨
var recordfile1Left: RecordingFile? = null
var recordfile1Right: RecordingFile? = null
// 扫描相关 // 扫描相关
private lateinit var scanHandler: Handler private lateinit var scanHandler: Handler
@ -147,9 +149,50 @@ object BleService {
// 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏 // 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏
private var latLastBleRightTs = 0L 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 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() 修改 // 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改
@Volatile @Volatile
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL
@ -171,15 +214,16 @@ object BleService {
private const val CHANNEL_HOLD_BUFFER_MAX = 10000 private const val CHANNEL_HOLD_BUFFER_MAX = 10000
// 左右同一拍都满足发送条件时的轮转标记,保证两声道公平、避免一方饿死。 // 左右同一拍都满足发送条件时的轮转标记,保证两声道公平、避免一方饿死。
private var holdDrainPreferLeft = true private var holdDrainPreferLeft = true
// ---- F4/F5 解码空余字节数 → 下发档位阈值(带迟滞)---- // ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ----
// 设备解码缓存总量≈1840B、单包160B(≈11.5包),按包步进上报。边界取 160(单包)整数倍, // 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后,
// 正好落在相邻上报值中间,避免临界抖动。降档敏感(及早减速防溢出)、升档保守(回升够多才提速), // 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。
// 中间留 2 包迟滞带,消除边界反复横跳。 // 无迟滞(直观好调);如遇边界抖动可再引入固定小迟滞。默认对齐历史行为(≥2880→40ms / ≥1120→160ms / 其余→320ms)。
// 阈值整体上调(补偿空余上报的网络延迟、留提前量):相对初始已上调 4 档(+640B≈4包),更早降速、更晚升速。 @Volatile private var tier1FreeBytes = 2880 // 第一档(最快):空余 ≥ 此值
private const val DECODE_FREE_CRIT_DOWN = 1120 // 空余 ≤7包:紧急降到 320ms(8x) @Volatile private var tier1SendMs = 40L // 第一档发送间隔(ms/包)
private const val DECODE_FREE_CRIT_UP = 1440 // 处于 320ms 时,回升到 ≥9包 才升回 160ms(4x) @Volatile private var tier2FreeBytes = 1120 // 第二档(中速):空余 ≥ 此值
private const val DECODE_FREE_NORMAL_DOWN = 1920 // 处于 40ms 时,跌到 ≤12包 才降到 160ms(4x) @Volatile private var tier2SendMs = 160L // 第二档发送间隔(ms/包)
private const val DECODE_FREE_NORMAL_UP = 2880 // 升到 ≥18包 才回 40ms(1x) 全速 @Volatile private var tier3FreeBytes = 0 // 第三档(最慢):其余(空余 < 第二档门限),此门限为兜底下界
@Volatile private var tier3SendMs = 320L // 第三档发送间隔(ms/包)
// 音频数据缓冲区,用于累积数据到80字节再发送 // 音频数据缓冲区,用于累积数据到80字节再发送
private val audioBuffer = mutableListOf<Byte>() private val audioBuffer = mutableListOf<Byte>()
// 重发机制相关常量 // 重发机制相关常量
@ -272,8 +316,12 @@ object BleService {
recordfile = RecordingFile(this.context) recordfile = RecordingFile(this.context)
recordfile!!.fileName = "耳机端" recordfile!!.fileName = "耳机端"
recordfile1 = RecordingFile(this.context) // 手机端下行按左右声道分别录制,便于 opus_test 分声道听辨
recordfile1!!.fileName = "手机端" // 命名用「翻译-*」前缀,opus_test 选中时自动套用手机端 opus 解码参数(16k/单/40/无头)
recordfile1Left = RecordingFile(this.context)
recordfile1Left!!.fileName = "翻译-左"
recordfile1Right = RecordingFile(this.context)
recordfile1Right!!.fileName = "翻译-右"
// 初始化Opus音频管理器(传入 context 以支持双声道编码时分别写 PCM WAV 调试文件) // 初始化Opus音频管理器(传入 context 以支持双声道编码时分别写 PCM WAV 调试文件)
if (!opusAudioManager.initialize(this.context)) { if (!opusAudioManager.initialize(this.context)) {
@ -828,6 +876,9 @@ object BleService {
*/ */
fun disconnect() { fun disconnect() {
Log.d(TAG, "主动断开当前连接") Log.d(TAG, "主动断开当前连接")
// 用户/正常主动断开:标记后取消任何进行中的通话翻译自动回连,避免断开又被自动连回
isManualDisconnect = true
cancelCallTranslationReconnect()
bluetoothGatt?.disconnect() bluetoothGatt?.disconnect()
updateConnectionState(BleConst.STATE_DISCONNECTING) updateConnectionState(BleConst.STATE_DISCONNECTING)
} }
@ -890,6 +941,10 @@ object BleService {
status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> { status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> {
updateConnectionState(BleConst.STATE_CONNECTED) updateConnectionState(BleConst.STATE_CONNECTED)
Log.d(TAG, "成功连接Ble蓝牙设备:${g.device.name}") Log.d(TAG, "成功连接Ble蓝牙设备:${g.device.name}")
// 通话翻译回连窗口内:GATT 已连上,记入 app 日志便于追踪整个回连过程
if (isDownlinkPaused) {
CallLog.i(TAG, "[CALL_RECONNECT] GATT 回连已连上,等待服务发现 dev=${g.device.name}")
}
companionManager?.unregisterBackgroundScan() companionManager?.unregisterBackgroundScan()
g.discoverServices() g.discoverServices()
@ -900,14 +955,32 @@ object BleService {
Log.d(TAG, "监听到已断开连接") Log.d(TAG, "监听到已断开连接")
disconnectGatt() disconnectGatt()
// 通话翻译进行中且非主动断开 → 走快速主动回连(暂停下行+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 pairedUuid = companionManager?.getPairedUuid()
val pairedMac = companionManager?.getPairedMacAddress() val pairedMac = companionManager?.getPairedMacAddress()
companionManager?.registerBackgroundScan(pairedMac, pairedUuid) companionManager?.registerBackgroundScan(pairedMac, pairedUuid)
} }
}
else -> { else -> {
updateConnectionState(BleConst.STATE_ERROR) updateConnectionState(BleConst.STATE_ERROR)
Log.d(TAG, "监听到连接错误") Log.d(TAG, "监听到连接错误")
// 回连过程中的连接错误(如 GATT 133)记入 app 日志,便于定位回连失败原因
if (isDownlinkPaused || isReconnecting) {
CallLog.w(TAG, "[CALL_RECONNECT] 回连过程出现连接错误 status=$status newState=$newState")
}
disconnectGatt() disconnectGatt()
} }
} }
@ -1034,6 +1107,17 @@ object BleService {
// 服务发现完成后,请求高优先级连接参数(最短连接间隔),提升 BLE 双向吞吐、 // 服务发现完成后,请求高优先级连接参数(最短连接间隔),提升 BLE 双向吞吐、
// 缓解上下行互相挤占导致的收发失败。连接断开后系统自动复位,无需手动还原。 // 缓解上下行互相挤占导致的收发失败。连接断开后系统自动复位,无需手动还原。
requestHighConnectionPriority(g) 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) { override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) {
@ -1044,14 +1128,16 @@ object BleService {
when (c.uuid) { when (c.uuid) {
// 音频特征数据 // 音频特征数据
BleConst.RECEIVE_AUDIO_CHAR_UUID -> { BleConst.RECEIVE_AUDIO_CHAR_UUID -> {
// 上行收包长度保护:非 40 倍数的异常包直接丢弃(不计入统计、不喂解码器)
if (!isValidUplinkAudioLen(data)) return
// 统计接收到的字节数 // 统计接收到的字节数
bytesReceivedInCurrentSecond += data.size bytesReceivedInCurrentSecond += data.size
processAudioData(data) processAudioData(data)
// Log.i(TAG, "lxm--收到音频特征数据, 字节数=${data.size}") // Log.i(TAG, "lxm--收到音频特征数据, 字节数=${data.size}")
} }
BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID -> { BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID -> {
// 上行收包长度保护:非 40 倍数的异常包直接丢弃(不计入统计、不喂解码器)
if (!isValidUplinkAudioLen(data)) return
// 统计接收到的字节数 // 统计接收到的字节数
bytesReceivedInCurrentSecond += data.size bytesReceivedInCurrentSecond += data.size
processAudioData(data) 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编码) * @param data 音频数据 (Opus编码)
@ -1409,6 +1507,7 @@ object BleService {
*/ */
private fun addAudioDataToSendQueue(data: ByteArray) { private fun addAudioDataToSendQueue(data: ByteArray) {
if (data.isEmpty()) return if (data.isEmpty()) return
// 重连窗口内不丢弃:继续入队,由发送线程在暂停期间抽干到左右声道缓存暂存,回连成功后按节拍继续下发。
if (!audioSendQueue.offer(data)) { if (!audioSendQueue.offer(data)) {
Log.w(TAG, "音频发送队列已满,丢弃当前包 size=${data.size}") Log.w(TAG, "音频发送队列已满,丢弃当前包 size=${data.size}")
} }
@ -1426,7 +1525,7 @@ object BleService {
isAudioSending.set(true) isAudioSending.set(true)
audioSendThread = Thread { audioSendThread = Thread {
Log.i(TAG, "音频发送线程已启动") CallLog.i(TAG, "音频发送线程已启动")
// 固定 40ms 一拍的发送节拍。每一拍做一次"发不发/发哪路"的决策;左右各按自身档位(paceTicks) // 固定 40ms 一拍的发送节拍。每一拍做一次"发不发/发哪路"的决策;左右各按自身档位(paceTicks)
// 控制频率(40/80/160ms),定时器始终稳定在 audioSendIntervalNormal 一拍,下发间隔不随 poll/发送耗时抖动。 // 控制频率(40/80/160ms),定时器始终稳定在 audioSendIntervalNormal 一拍,下发间隔不随 poll/发送耗时抖动。
@ -1435,6 +1534,21 @@ object BleService {
while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { while (isAudioSending.get() && !Thread.currentThread().isInterrupted) {
try { 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) 非阻塞抽干主队列,按声道分流到左右缓存(不在热路径阻塞,保证节拍稳定)。 // 1) 非阻塞抽干主队列,按声道分流到左右缓存(不在热路径阻塞,保证节拍稳定)。
// 合包布局:[4B 序号(小端)] + [1B 声道(0=左/1=右)] + [N × opus] // 合包布局:[4B 序号(小端)] + [1B 声道(0=左/1=右)] + [N × opus]
while (true) { while (true) {
@ -1514,18 +1628,24 @@ object BleService {
buffer.addFirst(audioChunk) buffer.addFirst(audioChunk)
writeFailRetryCount++ writeFailRetryCount++
if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { 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})") "hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size})")
} }
return false return false
} }
if (writeFailRetryCount > 0) { if (writeFailRetryCount > 0) {
Log.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount")
writeFailRetryCount = 0 writeFailRetryCount = 0
} }
// 调试录音文件只保留拼接后的纯 opus 数据,剥离 5 字节包头再写入(仅成功时写,避免重试重复) // 调试录音文件按声道分别保存(左右各自独立编码,混写无法解码听辨):
// 剥离 5 字节包头后,按声道标记写入对应声道文件(仅成功时写,避免重试重复)
if (audioChunk.size > 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) { if (audioChunk.size >= 5) {
@ -1547,15 +1667,17 @@ object BleService {
else -> 0L else -> 0L
} }
val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" 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) " + "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 耳机(每句首包)。 // [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE 耳机(每句首包)。
// 与 azure_speech 的 [LAT-TRACE] 点2(app收到译音) 同时钟相减 = app 内部下行耗时。 // 与 azure_speech 的 [LAT-TRACE] 点2(app收到译音) 同时钟相减 = app 内部下行耗时。
if (channelByte == OpusAudioManager.CHANNEL_RIGHT) { if (channelByte == OpusAudioManager.CHANNEL_RIGHT) {
if (now - latLastBleRightTs > 700) { 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 latLastBleRightTs = now
} }
@ -1576,7 +1698,7 @@ object BleService {
} }
if (buffer.size >= CHANNEL_HOLD_BUFFER_MAX) { if (buffer.size >= CHANNEL_HOLD_BUFFER_MAX) {
buffer.removeFirst() buffer.removeFirst()
Log.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧包 ch=${channelTag}") CallLog.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧包 ch=${channelTag}")
} }
buffer.addLast(chunk) buffer.addLast(chunk)
} }
@ -1587,6 +1709,15 @@ object BleService {
private fun stopAudioSendThread() { private fun stopAudioSendThread() {
isAudioSending.set(false) isAudioSending.set(false)
audioSendThread?.interrupt() audioSendThread?.interrupt()
resetDownlinkBuffers()
CallLog.i(TAG, "音频发送线程已停止,队列和缓冲区已清空")
}
/**
* 清空下行发送队列/左右声道缓存,并复位档位、包序、节拍与流控计数。
* 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。
*/
private fun resetDownlinkBuffers() {
audioSendQueue.clear() audioSendQueue.clear()
audioBuffer.clear() // 清空音频缓冲区 audioBuffer.clear() // 清空音频缓冲区
leftHoldBuffer.clear() // 清空左声道下行缓存 leftHoldBuffer.clear() // 清空左声道下行缓存
@ -1601,7 +1732,106 @@ object BleService {
lastRightSeq = -1L lastRightSeq = -1L
writeFailRetryCount = 0 // 重置写入拥塞计数 writeFailRetryCount = 0 // 重置写入拥塞计数
latLastBleRightTs = 0L // 重置延迟追踪计时 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 {
// ====================================================================================================== // ======================================================================================================
/** /**
* 设置通话翻译调试参数(运行时生效,无需重连)。 * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @param sendIntervalMs 音频下行发送间隔(ms),null 或 <=0 表示不修改 * @param sendIntervalMs 音频下行发送间隔(ms)
* @param bundleFrameCount 下行合包帧数,null 或 <=0 表示不修改 * @param bundleFrameCount 下行合包帧数
* @param t1FreeBytes/t1SendMs 第一档(最快):空余下限 / 发送间隔(ms)
* @param t2FreeBytes/t2SendMs 第二档(中速):空余下限 / 发送间隔(ms)
* @param t3FreeBytes/t3SendMs 第三档(最慢):空余下限(兜底) / 发送间隔(ms)
*/ */
fun setCallTranslationDebugParams(sendIntervalMs: Long?, bundleFrameCount: Int?) { 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) { if (sendIntervalMs != null && sendIntervalMs > 0) {
audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms")
@ -1778,16 +2017,36 @@ object BleService {
opusAudioManager.setBundleFrameCount(bundleFrameCount) opusAudioManager.setBundleFrameCount(bundleFrameCount)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${opusAudioManager.getBundleFrameCount()}") 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<String, Any> { fun getCallTranslationDebugParams(): Map<String, Any> {
return mapOf( return mapOf(
"sendIntervalMs" to audioSendIntervalNormal, "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 是否成功发送命令 * @return 是否成功发送命令
*/ */
fun closeCodec(): Boolean { fun closeCodec(): Boolean {
Log.i(TAG, "关闭编解码...") CallLog.i(TAG, "关闭编解码...")
// 结束通话翻译会话:取消任何进行中的自动回连并复位暂停状态,避免关闭后又被自动连回
isCallTranslating = false
cancelCallTranslationReconnect()
recordfile?.closeFile() recordfile?.closeFile()
recordfile1?.closeFile() recordfile1Left?.closeFile()
recordfile1Right?.closeFile()
val cmdData = byteArrayOf( val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_CLOSE.toByte(), BleConst.CODEC_CONTROL_CLOSE.toByte(),
BleConst.AUDIO_CHANNEL_STEREO.toByte() BleConst.AUDIO_CHANNEL_STEREO.toByte()
@ -1868,16 +2131,24 @@ object BleService {
* @return 是否成功发送命令 * @return 是否成功发送命令
*/ */
fun openA2DPDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { 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() startOpusEncodeStream()
recordfile1?.creatingFiles(); recordfile1Left?.creatingFiles();
recordfile1Right?.creatingFiles();
startOpusStreamDecoding(false, 2, 16000, 80) startOpusStreamDecoding(false, 2, 16000, 80)
val cmdData = byteArrayOf( val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(),
channelMode.toByte() channelMode.toByte()
) )
val ok = commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData) 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 return ok
} }
@ -2023,6 +2294,14 @@ object BleService {
return connectionState.value == BleConst.STATE_CONNECTED return connectionState.value == BleConst.STATE_CONNECTED
} }
/**
* 是否正处于通话翻译会话中。
* 供 BleAgent 等外部组件在 BLE 断连时判断:若为 true,则不要停掉语音服务(closeCodec),
* 由 BleService 内部的自动回连(暂停下行→1s回连→重进翻译态)接管,避免拆掉会话导致无法恢复。
* openA2DPDecoder() 置 true、closeCodec() 置 false,故它准确反映"当前是否在通话翻译"。
*/
fun isInCallTranslation(): Boolean = isCallTranslating
/** /**
* 获取缓存设备地址 * 获取缓存设备地址
* @return mac地址 * @return mac地址
@ -2122,7 +2401,8 @@ object BleService {
// 清理录音文件 // 清理录音文件
recordfile = null recordfile = null
recordfile1 = null recordfile1Left = null
recordfile1Right = null
// 停止音频处理 // 停止音频处理
stopAudioSendThread() stopAudioSendThread()
stopOpusStreamDecoding() stopOpusStreamDecoding()
@ -2204,16 +2484,16 @@ object BleService {
private fun onDecodeFreeReported(channel: Int, freeBytes: Int) { private fun onDecodeFreeReported(channel: Int, freeBytes: Int) {
when (channel) { when (channel) {
BleConst.AUDIO_CHANNEL_LEFT -> { BleConst.AUDIO_CHANNEL_LEFT -> {
val pace = nextPaceTicks(leftPaceTicks, freeBytes) val pace = nextPaceTicks(freeBytes)
if (leftPaceTicks != pace) { 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 leftPaceTicks = pace
} }
BleConst.AUDIO_CHANNEL_RIGHT -> { BleConst.AUDIO_CHANNEL_RIGHT -> {
val pace = nextPaceTicks(rightPaceTicks, freeBytes) val pace = nextPaceTicks(freeBytes)
if (rightPaceTicks != pace) { 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 rightPaceTicks = pace
} }
@ -2222,17 +2502,19 @@ object BleService {
} }
/** /**
* 带迟滞的三档流控:由当前档位 currentPace 与最新空余 freeBytes 决定新档位(拍)。三档=拍数 1/4/8 → 40/160/320ms/包。 * 简单三档流控(无迟滞):按最新空余 freeBytes 从第一档往下取第一个满足 freeBytes>=门限 的档,
* 降档敏感、升档保守,中间区按当前档位保持,避免边界反复横跳: * 得到该档发送间隔 sendMs,再按当前拍长 audioSendIntervalNormal 换算为拍数(paceTicks)。
* 空余 ≤CRIT_DOWN → 8(320ms);≥NORMAL_UP → 1(40ms); * 三档门限/间隔均可在通话调试界面运行时调整;发送线程按 paceTicks 控制该声道下发频率。
* 中间区:40ms 档跌破 NORMAL_DOWN 才降 160ms;320ms 档升过 CRIT_UP 才回 160ms;160ms 档维持。 * @return 拍数(≥1),即"每 paceTicks 拍发一包"。
*/ */
private fun nextPaceTicks(currentPace: Int, freeBytes: Int): Int = when { private fun nextPaceTicks(freeBytes: Int): Int {
freeBytes <= DECODE_FREE_CRIT_DOWN -> 8 val sendMs = when {
freeBytes >= DECODE_FREE_NORMAL_UP -> 1 freeBytes >= tier1FreeBytes -> tier1SendMs
currentPace == 8 -> if (freeBytes >= DECODE_FREE_CRIT_UP) 4 else 8 freeBytes >= tier2FreeBytes -> tier2SendMs
currentPace == 1 -> if (freeBytes <= DECODE_FREE_NORMAL_DOWN) 4 else 1 else -> tier3SendMs
else -> 4 }
// ms → 拍数:四舍五入到最近的整数拍,至少 1 拍(不快于全速拍长)
return Math.round(sendMs.toDouble() / audioSendIntervalNormal).toInt().coerceAtLeast(1)
} }
/** /**

27
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 dataEventChannel: EventChannel? = null
private var statusEventChannel: EventChannel? = null private var statusEventChannel: EventChannel? = null
private var callLogEventChannel: EventChannel? = null
// 应用上下文 // 应用上下文
private lateinit var context: Context 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接口 // 实现ActivityAware接口
@ -171,6 +185,8 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
// 解除事件通道 // 解除事件通道
dataEventChannel?.setStreamHandler(null) dataEventChannel?.setStreamHandler(null)
statusEventChannel?.setStreamHandler(null) statusEventChannel?.setStreamHandler(null)
callLogEventChannel?.setStreamHandler(null)
CallLog.setEmitter(null)
} }
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
@ -233,7 +249,16 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
"setCallTranslationDebugParams" -> { "setCallTranslationDebugParams" -> {
val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong() val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong()
val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt() val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount) val t1FreeBytes = (call.argument<Number>("tier1FreeBytes"))?.toInt()
val t1SendMs = (call.argument<Number>("tier1SendMs"))?.toLong()
val t2FreeBytes = (call.argument<Number>("tier2FreeBytes"))?.toInt()
val t2SendMs = (call.argument<Number>("tier2SendMs"))?.toLong()
val t3FreeBytes = (call.argument<Number>("tier3FreeBytes"))?.toInt()
val t3SendMs = (call.argument<Number>("tier3SendMs"))?.toLong()
BleService.setCallTranslationDebugParams(
sendIntervalMs, bundleFrameCount,
t1FreeBytes, t1SendMs, t2FreeBytes, t2SendMs, t3FreeBytes, t3SendMs
)
result.success(true) result.success(true)
} }
"getCallTranslationDebugParams" -> { "getCallTranslationDebugParams" -> {

73
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<String>()
@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<String>(buffer.size)
buffer.drainTo(batch)
if (batch.isEmpty()) return
try { e(batch.joinToString("\n")) } catch (_: Exception) {}
}
}

10
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt

@ -44,7 +44,7 @@ class OpusAudioManager {
// 下行合包默认帧数:左右各自累积 bundleFrameCount 帧 opus 合并为 1 包后才下发,降低 BLE 发送频率 // 下行合包默认帧数:左右各自累积 bundleFrameCount 帧 opus 合并为 1 包后才下发,降低 BLE 发送频率
// 调试界面可在运行时调整;通过 setBundleFrameCount() 修改 // 调试界面可在运行时调整;通过 setBundleFrameCount() 修改
const val DEFAULT_BUNDLE_FRAME_COUNT = 4 const val DEFAULT_BUNDLE_FRAME_COUNT = 5
@Volatile @Volatile
private var bundleFrameCount = DEFAULT_BUNDLE_FRAME_COUNT private var bundleFrameCount = DEFAULT_BUNDLE_FRAME_COUNT
// 合包头部:bytes 0-3 序号(小端 uint32),byte 4 声道标识(0=左/1=右) // 合包头部:bytes 0-3 序号(小端 uint32),byte 4 声道标识(0=左/1=右)
@ -484,7 +484,7 @@ class OpusAudioManager {
override fun onStart() { Log.i(TAG, "左声道独立编码已开始") } override fun onStart() { Log.i(TAG, "左声道独立编码已开始") }
override fun onComplete(outPath: String?) {} override fun onComplete(outPath: String?) {}
override fun onError(code: Int, message: String?) { override fun onError(code: Int, message: String?) {
Log.e(TAG, "左声道编码错误: [$code] $message") CallLog.e(TAG, "左声道编码错误: [$code] $message")
callback?.onError("encode", code, message) callback?.onError("encode", code, message)
} }
}) })
@ -495,14 +495,14 @@ class OpusAudioManager {
override fun onStart() { Log.i(TAG, "右声道独立编码已开始") } override fun onStart() { Log.i(TAG, "右声道独立编码已开始") }
override fun onComplete(outPath: String?) {} override fun onComplete(outPath: String?) {}
override fun onError(code: Int, message: String?) { override fun onError(code: Int, message: String?) {
Log.e(TAG, "右声道编码错误: [$code] $message") CallLog.e(TAG, "右声道编码错误: [$code] $message")
callback?.onError("encode", code, message) callback?.onError("encode", code, message)
} }
}) })
isDualEncoding = true isDualEncoding = true
callback?.onEncodeStreamStateChanged(true) callback?.onEncodeStreamStateChanged(true)
Log.i(TAG, "双声道独立编码流已启动 sampleRate=$sampleRate packetSize=$packetSize") CallLog.i(TAG, "双声道独立编码流已启动 sampleRate=$sampleRate packetSize=$packetSize")
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "启动双声道编码流失败: ${e.message}", e) Log.e(TAG, "启动双声道编码流失败: ${e.message}", e)
@ -527,7 +527,7 @@ class OpusAudioManager {
} }
closePcmWavWriters() closePcmWavWriters()
callback?.onEncodeStreamStateChanged(false) callback?.onEncodeStreamStateChanged(false)
Log.i(TAG, "双声道独立编码流已停止") CallLog.i(TAG, "双声道独立编码流已停止")
return true return true
} }

29
local_plugins/ble_service/lib/ble_service.dart

@ -175,15 +175,30 @@ class BleService {
/// 设置通话翻译调试参数(运行时生效) /// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms) /// [sendIntervalMs] 音频下行发送间隔(ms)
/// [bundleFrameCount] 下行合包帧数 /// [bundleFrameCount] 下行合包帧数
/// [tier1FreeBytes]/[tier1SendMs] 第一档(最快):空余下限 / 发送间隔(ms)
/// [tier2FreeBytes]/[tier2SendMs] 第二档(中速):空余下限 / 发送间隔(ms)
/// [tier3FreeBytes]/[tier3SendMs] 第三档(最慢):空余下限(兜底) / 发送间隔(ms)
Future<bool> setCallTranslationDebugParams({ Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs, int? sendIntervalMs,
int? bundleFrameCount, int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async { }) async {
try { try {
final result = await _methodChannel final result = await _methodChannel
.invokeMethod<bool>('setCallTranslationDebugParams', { .invokeMethod<bool>('setCallTranslationDebugParams', {
if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs, if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs,
if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount, 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; return result ?? false;
} catch (e) { } catch (e) {
@ -193,15 +208,23 @@ class BleService {
} }
/// 获取当前通话翻译调试参数 /// 获取当前通话翻译调试参数
/// 返回 {'sendIntervalMs': int, 'bundleFrameCount': int},失败返回空 Map /// 返回 {'sendIntervalMs','bundleFrameCount','tier1FreeBytes','tier1SendMs',
/// 'tier2FreeBytes','tier2SendMs','tier3FreeBytes','tier3SendMs'},失败返回空 Map
Future<Map<String, int>> getCallTranslationDebugParams() async { Future<Map<String, int>> getCallTranslationDebugParams() async {
try { try {
final result = await _methodChannel final result = await _methodChannel
.invokeMethod<Map>('getCallTranslationDebugParams'); .invokeMethod<Map>('getCallTranslationDebugParams');
if (result == null) return {}; if (result == null) return {};
int readInt(String k) => (result[k] as num?)?.toInt() ?? 0;
return { return {
'sendIntervalMs': (result['sendIntervalMs'] as num?)?.toInt() ?? 0, 'sendIntervalMs': readInt('sendIntervalMs'),
'bundleFrameCount': (result['bundleFrameCount'] as num?)?.toInt() ?? 0, 'bundleFrameCount': readInt('bundleFrameCount'),
'tier1FreeBytes': readInt('tier1FreeBytes'),
'tier1SendMs': readInt('tier1SendMs'),
'tier2FreeBytes': readInt('tier2FreeBytes'),
'tier2SendMs': readInt('tier2SendMs'),
'tier3FreeBytes': readInt('tier3FreeBytes'),
'tier3SendMs': readInt('tier3SendMs'),
}; };
} catch (e) { } catch (e) {
print('获取通话翻译调试参数失败: $e'); print('获取通话翻译调试参数失败: $e');

Loading…
Cancel
Save