# 恒玄(BES)耳机 SPP 协议梳理 —— 来自 deepvoice 项目 来源:`/Users/yunyanzhineng/work/src/deepvoice` 链路:经典蓝牙 RFCOMM,SPP UUID `aeaf5241-5453-7070-7353-445542594c46` 本项目对应封装:`lib/data/services/bes_bluetooth_service.dart` + `local_plugins/bluetooth_manager` (这套 native 插件 eaimar 已经完整拉过来了,两边的 `BluetoothManager.kt` 一致, 所以**下行指令发送、上行事件解析、G.722 / mSBC 编解码全部现成可用**。) --- ## 一、帧格式 | 方向 | SOF | 结构 | |---|---|---| | App → 耳机 | `0xAA` | `AA CMD [len] [payload…]`,短指令只有 `AA CMD` 两字节 | | 耳机 → App | `0xBB` | `BB CMD len(1~2) [payload…]` | native 侧 `ConnectedThread.run()` 按 `0xBB / 0xCC` 拆帧,`handleCommand()` 按 `data[1]` 分发;未识别的 CMD 一律以 `type: "unknown"` + 原始字节抛给 Dart,由 Dart 二级解析 (本项目 `BesBluetoothService._handleRawFrame` 就是这么处理固件版本回包的)。 --- ## 二、App → 耳机(下行指令) ### 1. 设备信息 / 设置 | 指令 | 含义 | 回包 | deepvoice 出处 | |---|---|---|---| | `AA 06` | 查询固件版本 | `BB 07` 主动上报 / `BB 86` 响应 | `bluetooth_service.dart:57` **已接入本项目** | | `AA 10` | 查询 EQ 配置 | `BB 90` | `eq_settings_controller.dart:76` | | `AA 0C` | 查询按键功能配置 | `BB 8C` | `eq_settings_controller.dart:78` | | `AA 0F 06 02 01 ` | 设置 EQ | — | `eq_settings_controller.dart:148` | | `AA 0B 06 02 ` | 设置按键功能(attr=按键位,value=功能码) | — | `eq_settings_controller.dart:192` | | `AA 0E` | 进入同声翻译 / 会议助手页面前下发。**代码里没有注释说明用途**,推测是通知耳机切到「APP 功能模式」,需向固件确认 | — | `home_view.dart:239,259` | ### 2. 通话翻译(双向,G.722 双声道) | 指令 | 含义 | 出处 | |---|---|---| | `AA 51` | 进入通话翻译/通话模式(耳机开始上传双路 G.722) | `CallTranslationHelper.start()` | | `AA 52` | 退出通话模式 | `CallTranslationHelper.stop()` | | `AA 57` | 通知耳机「ASR 已开启」(收到耳机 `0xD1` 后发) | `startRecognition()` | | `AA 58` | 通知耳机「ASR 已关闭」(收到耳机 `0xD2` 后发) | `stopRecognition()` | | `AA 56 54 03 [40B spk][40B mic]` | **下行 TTS 音频**:84 字节定长包,`len=84`,`mode=0x03`(双声道),右耳=对端译文、左耳=己方译文;空槽填静音帧 | `sendAudioData()` | ### 3. 面对面翻译(手机麦上行,耳机放译文) | 指令 | 含义 | 出处 | |---|---|---| | `AA 66` | 开始面对面翻译 | `FaceToFaceTranslationHelper.start()` | | `AA 67` | 结束面对面翻译 | `stop()` | | `AA 69 2C 01 [40B G.722]` | 下行 TTS 音频,44 字节包,`mode=0x01`(单声道) | `sendAudioData()` | ### 4. 现场录音 / 同声翻译(耳机主麦,mSBC) | 指令 | 含义 | 出处 | |---|---|---| | `AA 59` | 开始主麦录音(耳机开始上传 mSBC 流) | `meeting_record_controller._startBtLiveRecord()`、`SimultaneousTranslationHelper.start()` | | `AA 5A` | 停止主麦录音 | `_stopBtLiveRecord()`、`SimultaneousTranslationHelper.stop()` | > 同声翻译的 BT 路径**直接复用现场录音的 59/5A 指令**,只是把 PCM 喂给 ASR 而不是写文件。 ### 5. 媒体(音视频)录音 | 指令 | 含义 | |---|---| | `AA 5E` | 开始媒体录音(audioType=1,录耳机播放的音频) | | `AA 5F` | 停止媒体录音 | ### 6. 通话录音 `meeting_record_controller` 里 audioType=2(通话录音)用的就是 `AA 51` / `AA 52`, 和通话翻译同一组指令——**耳机侧只有「进入/退出通话双路音频模式」这一个开关**, 录音还是翻译取决于 App 拿到 `spkData/micData` 之后怎么处理。 ### 7. AI 实时对话 | 指令 | 含义 | 出处 | |---|---|---| | `AA 6A` | **3 秒内**回应耳机的 `0x64`,确认真正进入 AI 对话,否则耳机停止传音频 | `AgentSessionCoordinator.startSession()` | | `AA 65` | 请求退出 AI 模式(正常路径等耳机回 `0xE5`,2s 未回则强制结束) | `requestStop()` | | `AA 69 …` | 下行 AI 语音(同面对面翻译的包格式,`mode=0x01`) | `HeadsetAudioLink.sendOnePacket()` | --- ## 三、耳机 → App(上行事件) native `handleCommand()` 的完整分发表(`BluetoothManager.kt:180`): | CMD | 事件 type | 分发去向 | 含义 | |---|---|---|---| | `0xD1` | `startCall` | translation + cmd | 通话开始(用户接通) | | `0xD2` | `stopCall` | translation + cmd | 通话结束 | | `0x02` | `startMakeCall` | cmd | 去电开始 | | `0x03` / `0x04` | `stopMakeCall`(同时发 `stopCall` 给翻译) | cmd | 去电结束 | | `0x8E`(`data[6]=02/03`) | `startMakeCall` / `stopMakeCall` | cmd | 通话状态的另一种上报格式 | | `0xD9` | `startMicRecording` | translation + cmd | 主麦录音已开始(响应 `AA 59`) | | `0xDA` | `stopMicRecording` | translation + cmd | 主麦录音已停止(响应 `AA 5A`) | | `0xDE` | `startMusic` | cmd | 媒体播放/录音开始(响应 `AA 5E`) | | `0xDF` / `0x61` | `stopMusic` | cmd | 媒体停止 | | `0xE6` / `0xE7` | `startFaceToFace` / `stopFaceToFace` | translation | 面对面翻译开/关(耳机按键触发) | | `0x64` / `0xE5` | `startAI` / `stopAI` | ai | AI 对话唤醒 / 结束 | | `0xD6` / `0xE9` | `speedAdjustment` | translation + ai | 下行发包节奏调整:`data[6]` = 01→16ms / 02→20ms / 03→25ms | | `0x55` / `0x68` / `0x60` | —(走音频解码) | — | **G.722 音频流**,见下 | | `0x5B` | —(走音频解码) | — | **mSBC 音频流**,见下 | | 其它 | `unknown` + 原始字节 | cmd | Dart 侧二级解析(固件版本 `0x07/0x86` 就在这里) | ### 音频流解码(native 已实现,直接可用) **G.722(`0x55/0x68/0x60`)** — `data[3] & 0x0F` 是声道模式: | mode | 含义 | 解码后回调 | |---|---|---| | `0x01` | 单路:本端麦克风 | `micData` | | `0x02` | 单路:对端/耳机麦 | `spkData`(同时喂给 AI 链路,AI 模式下耳机麦实测是按 spk 标记上行的) | | `0x03` | 双路交织,每 40 字节一块交替 | 交替 `spkData` / `micData` | **mSBC(`0x5B`)** — 每 57 字节一帧,交替解出:`mainMicData`(主麦)/ `ffMicData`(前馈麦)。 两种解码输出统一是 **16kHz / 16bit / 单声道 PCM**,并且: - 全部通过 `onDeviceDataReceived()`(EventChannel `bluetooth_manager/read`)发给 Dart; - 同时按类型发给 native 的 `translationCallback` / `aiCallback` 槽。 --- ## 四、和本项目功能的对应关系 eaimar 的翻译/ASR 栈在 **Dart 侧**(`azure_speech` / `volcano_speech` / `bytedance_speech`), deepvoice 的在 **native 插件**(`asr_tr_tts_manager` / `agent_manager`,eaimar 没拉这两个)。 所以对接方式是:**指令用 `BesBluetoothService.sendData()` 发,音频从 `onDeviceDataReceived()` 取 PCM 喂现有 Dart ASR**, 不需要把那两个 native 插件搬过来。 | 本项目功能 | 入口 | 需要的下行指令 | 需要监听的上行事件 | 音频来源 | 备注 | |---|---|---|---|---|---| | **通话翻译**(AI 页) | `HomeController.openTranslationFeature('call')` → `Routes.translation` | 进页 `AA 51`,退页 `AA 52`;收到 `0xD1` 后 `AA 57`,收到 `0xD2` 后 `AA 58` | `startCall(0xD1)` / `stopCall(0xD2)` / `speedAdjustment` | G.722 mode=0x03 双路 → `spkData`=对端、`micData`=本端 | 译文要回放到耳机需要按 `AA 56` 84字节包上行,G.722 编码用 native 已有的 `G722Codec.encodeSync`(当前 Dart 侧没有暴露编码接口,要加一个 method channel) | | **面对面翻译** | `Routes.translation` (`faceToFace`) | 开始 `AA 66`,结束 `AA 67` | `startFaceToFace(0xE6)` / `stopFaceToFace(0xE7)`(耳机按键也能触发) | 己方=手机麦;对端=耳机 `spkData` | 译文下行 `AA 69` 44 字节包,mode=0x01 | | **同声翻译** | `Routes.translation` (`simultaneous`) | 连了耳机走 `AA 59` / `AA 5A`;没连走手机麦 | `startMicRecording(0xD9)` / `stopMicRecording(0xDA)` | mSBC → `mainMicData` | 与现场录音共用指令,只是 PCM 去向不同 | | **通话录音** | `HomeController.openCallRecording()` → `CallRecordingController` | `AA 51` / `AA 52` | `startCall` / `stopCall` | 同通话翻译:`spkData`=右声道、`micData`=左声道 | deepvoice 存的是**双声道 WAV**(左=本端麦、右=对端),本项目要照这个存 | | **现场录音** | `HomeController.openFieldRecording()` | `AA 59` / `AA 5A` | `startMicRecording` / `stopMicRecording` | mSBC → `mainMicData`(隔帧取主麦) | 注意:`meeting_record_controller._mainMicBuilderAdd` 是**隔一帧取一帧**(主麦/前馈麦交替上行) | | **AI 实时对话** | `lib/modules/agent` | 收到 `0x64` **3 秒内**必须回 `AA 6A`;退出发 `AA 65` | `startAI(0x64)` / `stopAI(0xE5)` / `speedAdjustment(0xE9)` | 耳机麦以 G.722 mode=0x02 上行 → `spkData` | 会话只能由耳机长按发起,App 侧只能确认/停止 | | **固件升级页** | `Routes.OTA_UPGRADE` | `AA 06` | `0x07` / `0x86` | — | 版本解析已在 `BesBluetoothService._parseFirmwareVersion` 落地,设备页右下角已显示 | | **EQ / 按键设置** | `lib/modules/eq_settings` | `AA 10`、`AA 0C` 查询;`AA 0F…`、`AA 0B…` 设置 | `0x90`(EQ 回包)、`0x8C`(按键回包) | — | 目前 eq_settings 模块走的是杰理链路,接恒玄要另加一路 | ### 已经就位 / 还缺的 **已就位**(native 插件拉过来时一并带入,不用再写): - 帧拆包、`handleCommand` 全量事件分发 - G.722 / mSBC 解码 → 16k PCM,走 `onDeviceDataReceived()` - RFCOMM 连接、ACL 广播自动回连 - `sendData()` 下行通道、固件版本查询与解析 **还缺**(要在本项目补的): 1. **下行音频编码接口**:native 有 `G722Codec.encodeSync`,但 `bluetooth_manager` 的 method channel 没暴露, 通话翻译 / 面对面翻译 / AI 的 TTS 回放需要加一个 `encodeG722` 方法,或者把打包逻辑 (`AA 56` / `AA 69`)直接做进 native,Dart 只丢 PCM。 2. **PCM 分流器**:`onDeviceDataReceived()` 只给一条裸 PCM 流,分不清 spk/mic/mainMic。 deepvoice 靠 native 的 `translationCallback` 分类型。本项目建议在 `bluetooth_manager` 的 cmd EventChannel 上补一个带 `type` 的音频事件,或者按 deepvoice 的做法在 Dart 侧靠**交替计数**分声道 (`_callBuilderAdd` / `_mainMicBuilderAdd` 就是这个套路)。 3. **三条链路的互斥**:deepvoice 里 translation 与 AI 共用 `0x68` 上行 / `AA 69` 下行,靠 `translationCallback != null` 做守卫。本项目接进来后同样要保证「通话翻译 / 面对面翻译 / AI 对话」 同一时刻只有一个在跑。 4. **按键事件落业务**:目前 `BesBluetoothService.lastCmdType` 只记录不处理, 需要把 `startMicRecording / stopMicRecording / startCall / stopCall / startFaceToFace / startAI` 接到对应页面的控制器上(参考支架那条链路的 `HolderRecordingService` 常驻服务写法)。 --- ## 五、注意事项(实测) - **TWS 双地址**:DEEPVOICE 在系统配对列表里是 `F0:A9:68:94:C1:C4`, 但能跑 SPP 的是 `F0:A9:68:94:BE:F8`。连接时要按 「原生当前已连接地址 → 上次成功地址 → 配对列表地址」依次尝试 (`bes_scan_view.dart` 已按此实现)。 - **AI 的 3 秒窗口**:`0x64` 之后必须在 3s 内发 `AA 6A`,deepvoice 是在建 WebSocket **之前**先发, 防止弱网握手把窗口耗光。 - **调速指令**:`0xD6/0xE9` 的 `data[6]` 决定下行发包间隔(16/20/25ms), 下行音频一旦要做,这个必须跟着变,否则耳机侧会缓冲溢出或断续。 - **AI PEN 不是这条链路**:`meeting_connect_controller.dart` 里那组 `0x5A 01 xx` 指令 (`0x0d` 查新文件、`0x87` 开热点、`0x0b/0x0c` 取 SSID/密码、`0x03~0x06` 取 FTP 参数) 走的是 BLE(FFF0/FFF1/FFF2)+ WiFi + FTP 的录音笔导文件流程,和恒玄耳机 SPP 无关。