12 KiB
恒玄(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 <value> |
设置 EQ | — | eq_settings_controller.dart:148 |
AA 0B 06 02 <attr> <value> |
设置按键功能(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()(EventChannelbluetooth_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()下行通道、固件版本查询与解析
还缺(要在本项目补的):
- 下行音频编码接口:native 有
G722Codec.encodeSync,但bluetooth_manager的 method channel 没暴露, 通话翻译 / 面对面翻译 / AI 的 TTS 回放需要加一个encodeG722方法,或者把打包逻辑 (AA 56/AA 69)直接做进 native,Dart 只丢 PCM。 - PCM 分流器:
onDeviceDataReceived()只给一条裸 PCM 流,分不清 spk/mic/mainMic。 deepvoice 靠 native 的translationCallback分类型。本项目建议在bluetooth_manager的 cmd EventChannel 上补一个带type的音频事件,或者按 deepvoice 的做法在 Dart 侧靠交替计数分声道 (_callBuilderAdd/_mainMicBuilderAdd就是这个套路)。 - 三条链路的互斥:deepvoice 里 translation 与 AI 共用
0x68上行 /AA 69下行,靠translationCallback != null做守卫。本项目接进来后同样要保证「通话翻译 / 面对面翻译 / AI 对话」 同一时刻只有一个在跑。 - 按键事件落业务:目前
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 无关。