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 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<String> _queue = <String>[];
@ -100,8 +103,10 @@ class Logger {
/// 打开/创建当前日志文件
static Future<void> _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;

21
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<bool> 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()}');

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 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,

12
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());

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';
/// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发,
/// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。
class CallTranslationDebugController extends GetxController {
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 _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<int>(key);
if (v != null) target.value = v.clamp(lo, hi);
}
/// 用原生当前实际值同步显示(不覆盖用户本地保存的偏好,仅在无本地保存时参考)
/// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值)
Future<void> _syncFromNative() async {
try {
final params = await _bleManager.getCallTranslationDebugParams();
if (params.isEmpty) return;
// 仅当本地没有保存过时,采用原生返回值
if (_storage.read<int>(_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<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) {
// 按 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<void> 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 ? '参数已应用' : '参数下发失败(请确认设备已连接)',

100
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<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) {
return Column(
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 {
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),
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) {
selectedFiles.clear();
selectedFiles.add(file);
// 「翻译-*」文件自动套用手机端下行 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;
}
// 切换通道模式
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 {
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,
),
),
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,
),
],
),
],
),
),
// 外部文件列表

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

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 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) {

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
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,

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 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<Byte>()
// 重发机制相关常量
@ -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()
@ -900,14 +955,32 @@ object BleService {
Log.d(TAG, "监听到已断开连接")
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 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 表示不修改
* 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @param sendIntervalMs 音频下行发送间隔(ms)
* @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) {
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<String, Any> {
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)
}
/**

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 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<Number>("sendIntervalMs"))?.toLong()
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)
}
"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 发送频率
// 调试界面可在运行时调整;通过 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=右)
@ -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
}

29
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<bool> setCallTranslationDebugParams({
int? sendIntervalMs,
int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async {
try {
final result = await _methodChannel
.invokeMethod<bool>('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<Map<String, int>> getCallTranslationDebugParams() async {
try {
final result = await _methodChannel
.invokeMethod<Map>('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');

Loading…
Cancel
Save