22 KiB
通话翻译:流程与控流协议
⚠️ 下行部分已过时(v2 改版后)。下行音频现已改为无包头、左右帧级交替的新格式,且 F4/F5 反馈调速已从代码中移除。 本文第 8 章(下行链路与控流)、10.2 节的 F4/F5 部分、11 章的默认参数均不再反映当前代码,勿据此对接。 下行的权威定义见 →
通话翻译-BLE协议规范-v2-固件对接.md本文其余部分(术语与声道语义、上行链路、端到端翻译层、指令协议、连接层优化)仍然有效。
适用分支:
newdev_chengguofeng(以当前 HEAD 实际代码为准) 关键词:Jieli 耳机 · BLE 双声道 · 端到端语音翻译(AST/STS) · 下行流控
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(Kotlinobject单例)在同一进程,下行译音 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)一次做三件事:
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(立体声),处理流程:
- 拆交错立体声:每样本 4 字节(左右各 2 字节 PCM16 小端)→
leftBuffer(己方麦克风) /rightBuffer(对方),同时计算左右peak。 - 上行控流 · 静音门控:
- 某路连续
silenceFramesToMute = 50帧(单帧 20ms ≈ 1s)peak < gatePeakThreshold(500)视为静音 → 改推零 PCM 保活。 - 目的:端到端 STS 服务(豆包/阿里)持续收到空音频会触发
stream is done/audio not enough(1011)/session timeout 断连;推零帧保持流不断。
- 某路连续
- 上行控流 · 噪声门
filterLowVolumeAudio:低于lowVolumeThreshold的样本置 0,去底噪。 - 分发:
pushAstAudioToA(leftBuffer)/pushAstAudioToB(rightBuffer),按currentAstProvider打到xxxAstHelperA/B.pushAudioData()。 - 回声门控
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:private val bPcmWriter = AudioWriter { data -> if (astStereoRecorder.isActive) astStereoRecorder.enqueueRightChannel(data) // 立体声录音右轨 if (broadcastPeerTranslate) callBPcmPlayer.feed(data) // 可选本地播报 bleWriteScope.launch { bleRightMutex.withLock { BleService.writeExternalRightAudioData(data) }} // 下发右声道 }
- A 路 TTS PCM → 左声道:
- TTS PCM 来源:
AstCallbacks.kt各家合成回调 →audioWriter?.write(pcmData):AzureonSynthesisAudioGenerated、豆包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 时隙(表现为「设备端发数据失败」)。服务发现完成后依次做:
- CCCD 串行注册:按队列逐个开启
callAudio / audio / notify / wakeupVoices通知。 CONNECTION_PRIORITY_HIGH:缩短连接间隔、增大双向带宽(走 HCI 连接参数更新,不占 GATT 队列;代价更耗电,断开自动复位)。- PHY 2M:
readPhy→ 延时setPreferredPhy(2M)(API 26+,失败回退 1M)。 - 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. 注意事项 / 已知不一致
- 合包序号字节序:实际为小端;
BleService.ktstartOpusEncodeStream上方注释误写「大端 / 5×opus / 205B」,属旧注释残留,以实现为准。 - 异步编码:
writeEncodeStream非同步,句尾最后几帧可能仍在编码器内部队列——当前流式下发不做整句边界对齐,靠合包+节拍平滑输出。 - iOS 对称实现:
ble_service/ios与azure_speech/ios有同名对称文件;下行整句相关的高级 gate 逻辑(若未来引入)iOS 侧需单独实现。 - 非通话翻译指令(单次对话 0x09/0x10、唤醒 0x11、唤醒词 OTA)与本功能共用同一 BLE 通道与指令协议,但不属于通话翻译数据流,本文不展开。