You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

22 KiB

通话翻译:流程与控流协议

适用分支: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)一次做三件事:

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