21 changed files with 1345 additions and 110 deletions
@ -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 通道与指令协议,但不属于通话翻译数据流,本文不展开。 |
||||
@ -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) {} |
||||
|
} |
||||
|
} |
||||
@ -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) {} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue