Browse Source

client(ios),docs: bundle id 修正、版本号 1.0.2+21,CLAUDE.md 记录本轮四条结论

- GoogleService-Info.plist 三份副本的 BUNDLE_ID 由上一代的 com.saitong.voitrans
  改成真实的 com.yimai.eaimar。⚠️ PROJECT_ID 仍是 voitrans:plist 是从 Firebase
  控制台下载的,要正的话得在那边注册新的 iOS 应用再重新下载三份。
- pubspec 版本 1.0.2+19 → +21;Xcode 自动改写的工程文件(MARKETING_VERSION 1.0.2)。
- CLAUDE.md 新增/更新四节:通话翻译耳机杂音的四个独立原因;德语/意大利语卡死的
  并发初始化竞态与三层修法;版本更新判定的 2026-09-23 新口径;后台上传 APK 报
  「必须先选择应用」的原因(X-App-Id 只有绑定应用的账号才有)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
98e640ecfe
  1. 299
      CLAUDE.md
  2. 2
      apps/client/GoogleService-Info.plist
  3. 2
      apps/client/assets/config/GoogleService-Info.plist
  4. 388
      apps/client/docs/face_to_face_ble_protocol.md
  5. 22
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  6. 2
      apps/client/ios/Runner/GoogleService-Info.plist
  7. 2
      apps/client/pubspec.yaml

299
CLAUDE.md

@ -70,14 +70,14 @@ iOS 首次还需 `cd ios && pod install`。
### 构建镜像与部署 ### 构建镜像与部署
```bash ```bash
./build.sh console # 构建 registry.voitrans.net/starpivot-console:latest(PUSH=0 只构建不推) ./build.sh console # 构建 registry.ymaikj.com/starpivot-console:latest(PUSH=0 只构建不推)
./build.sh admin ./build.sh admin
# 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin) # 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin)
cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境 cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境
# 生产:先构建带版本号的镜像,再部署 # 生产:先构建带版本号的镜像,再部署
cd deploy/console && ./prod-build.sh 0.2.1 along # 第 2 个参数选公司(yunyan|lingpu|along),省略则交互选 cd deploy/console && ./prod-build.sh 0.2.1 along # 第 2 个参数选公司,目前只剩 along,可省略
cd deploy/console && ./prod-deploy.sh 0.2.1 along # 第 2 个参数选生产环境(目前只有 along) cd deploy/console && ./prod-deploy.sh 0.2.1 along # 第 2 个参数选生产环境(目前只有 along)
cd deploy/app && ./prod-deploy.sh ym along 0.2.1 # app 按「应用 区域 版本」部署 cd deploy/app && ./prod-deploy.sh ym along 0.2.1 # app 按「应用 区域 版本」部署
``` ```
@ -94,11 +94,13 @@ cd deploy/console && DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy
⚠️ **2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从所有脚本移除**,`ENVS`/`PROD_ENVS`/`REGIONS` ⚠️ **2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从所有脚本移除**,`ENVS`/`PROD_ENVS`/`REGIONS`
都只剩 `along`。那 6 台(云雁测试/上海/日本/新加坡、灵谱测试/正式)的 SSH 私钥路径指向另一台开发机, 都只剩 `along`。那 6 台(云雁测试/上海/日本/新加坡、灵谱测试/正式)的 SSH 私钥路径指向另一台开发机,
本机**一台都连不上**,留在菜单里只会误选。**IP 与私钥路径不再记在这里,需要时从 git 历史取回。** 本机**一台都连不上**,留在菜单里只会误选。**IP 与私钥路径不再记在这里,需要时从 git 历史取回。**
**镜像仓库没有动**:`prod-build.sh` 仍可选 `yunyan` / `lingpu` 推镜像,三家仓库本机都能读写。
**镜像仓库按公司区分**,地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它): ⚠️ **2026-09-23:云雁/灵谱的镜像仓库档案也删了**(此前刻意保留的那条已推翻)。
- `yunyan` → `registry.voitrans.net`(云雁自有仓库;**只用于推镜像,本仓库已无云雁的部署目标**) `deploy/registry-profiles.sh` 的 `COMPANIES` 只剩 `along`,`prod-build.sh` 不再能推到
- `lingpu` → `registry.lingpu.net`(灵谱自有仓库;同上) `registry.voitrans.net` / `registry.lingpu.net`;三个 `docker-compose.yml` 与 `build.sh` 里
写死的兜底默认值也从 `registry.voitrans.net` 改成了阿龙仓库。要找回:`git log -- deploy/registry-profiles.sh`。
**镜像仓库**地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它):
- `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像 - `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像
测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`,**只有 `along` 一个**: 测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`,**只有 `along` 一个**:
@ -904,6 +906,56 @@ iPhone 上每次都退出重进测就碰不到,要复现就同页连开两场
- ⚠️ 同一房间用两台手机测试时,B 路(对端通话音)吃到的就是机主自己的声音,`en->zh` 那条腿会把 - ⚠️ 同一房间用两台手机测试时,B 路(对端通话音)吃到的就是机主自己的声音,`en->zh` 那条腿会把
中文原话原样吐回来并合成中文 TTS 灌回耳朵,听感像"慢了 3 秒又念了一遍",那是测试条件不是 bug。 中文原话原样吐回来并合成中文 TTS 灌回耳朵,听感像"慢了 3 秒又念了一遍",那是测试条件不是 bug。
### 通话翻译耳机里的"偶尔杂音":六轮真机定位,四个独立原因(2026-09-23,Android 定性,iOS 同步未验)
杂音不是一个原因,是四个叠在一起,每修掉一个概率就降一档。逐条都有日志证据,别再从"耳机坏了"猜起:
1. **发包定时器系统性漂移**:`scheduleWithFixedDelay` 从上一次任务**结束**起算,WSOLA + G.722 + 写 socket 的
1~3 ms 全叠进周期,"20 ms"实测 21~23 ms、只有 0.9 倍速;耳机缓冲一直往下掉,62% 的时间在要 16 ms,
两边每 80 ms 拉锯一次。判据是协议栈 `PORT_WriteData p_len=84` 行的时间间隔分布。改成 `scheduleAtFixedRate`
固定节拍且**永不重排**(iOS 的 `DispatchSourceTimer` 本来就是固定节拍)。8-29 最初版和 deepvoice 都有这个 bug。
2. **起播缓冲在句中重新生效**(杂音落在句中的真凶):队列空了就要求再攒 120 ms 才恢复,而变速追赶时消耗比
云端到达快、队列中途归零很常见,等于句中硬插 120 ms 静音。现在 `playing` 标志让起播缓冲只在句首用一次,
句中有一帧就播;真正的断粮(云端没跟上)单独计数并打 `句中空洞` 日志,修后实测 0 次。
3. **恢复发包时从空缓冲起播**(杂音落在句首):两路都空我们就停发,耳机缓冲被榨干,下一句第一包直接起播。
耳机每次都回 `bb d6 07 00 02 01 00`(data[6]=00 = 缓冲空)。现在先塞 4 包**编码静音**垫底再发真音频,
两路都空后再续 500 ms 静音才停。
4. **24k→16k 重采样**是逐块独立的线性插值、无抗混叠、每块相位从零重算。换成 64 阶低通 + 跨块连续
(`Resampler24kTo16k.kt/.swift`,Python 复刻校验 1k/3k/6k 正弦)。
顺带定性的几条:
- 耳机 `0xD6` 帧只有 data[6] 一个字节有信息(`bb d6 07 00 02 01 XX`),**01 一次只持续 20 ms 就回 02**,
它的语义是"缓冲低一包,现在补一包",不是"把速率改成 1.25x"。现在 01 立刻补一包(并按 16 ms 配额跟上持续要求)、
00 补两包、03 扣一包(`onHeadsetPace`)。三轮压测里它**一直在发**,从没要过 03——9-22 那句"耳机从未发过调速"是
grep 错了字样(日志文本是 `下行节奏调整为 Nms`)。
- **SPP 上只能 1 帧/84B**:试过 deepvoice iOS 那种 3 帧/244B,耳机完全不认、全是杂音。
- RFCOMM 是信用流控,耳机一次只放 1~3 个信用;信用停顿时协议栈把攒下的 6~11 包合成一帧突发,中间
100 多 ms 空档——这一层我们控制不了,能做的只有让耳机缓冲别贴着底走(上面第 3、5 条)。华为手机上系统的
「查找网络」/Nearby 会周期性 BLE 扫描(`scan_int=960 scan_win=96`),也会挤经典蓝牙时隙,排查时可临时关掉对照。
- 空闲那一路的静音帧改为用该路自己的编码器编零(G.722 自适应差分,固定码流会让解码器状态跳);
裁静音加回滞(300 进 / 600 出)+ 接缝 3 ms 淡入;速率每帧最多变 0.03;WSOLA 搜索窗 ±10 ms;
一句话没结束(150 ms 内有新数据)不补零收尾。`HARD_CAP_SEC` 补回 30 s 纯兜底。
- `DEEPVOICE_PARITY` 是打对照包用的开关(不裁不变速、4096 帧丢最旧、不打日志),**必须为 false 提交**。
### 通话翻译"德语/意大利语一开就卡死、只有中英正常":并发初始化 + 默认参数兜底(2026-09-23 Redmi K90 定性并修)
不是语种问题,是**竞态**。原生阿里初始化天然 ≥1.2 s(A 路 init → `delay(1200)` → B 路 init),期间只要再来一次
initialize / dispose,"代数"变了,前一次**返回 false**;Dart `initialize()` 拿到 false 把 `_isInitialized` 清掉;
接着 `recognizeCallback()` 看到"未初始化"就调**无参** `initialize()`——默认值 `provider=azure` + `[zh-CN, en-US]`,
把刚握手成功的阿里 zh→de 会话整个换成 Azure 中英三段拼装。此后原生 provider 停在 azure,Azure 识别器一直在跑,
每次 dispose 都慢几秒,下一轮两条初始化更容易撞进 1.2 s 窗口,**退出重进也不会好**。中英"正常"只是因为默认值恰好是中英。
触发源:切语言的 `stopAll`+`_reinitializeAsrService`、`startRecognition` 等 `_modeInitFuture` 3 s 超时后硬往下开桥、
退出页面 `onClose` 与重进 `onInit` 叠上再点开始。判据:日志里 `[STS] initialize: provider=azure (native=azure)` 紧跟在
阿里的 `[TRANS-INIT] 命中` 之后,或 `pushAstAudioToA: provider=azure`。
修在三层,**缺一层都会复发**:
- `azure_ast_service.dart`:`initialize()` 串行化(`_initInFlight`);记住最近一次 provider/语言表;`recognizeCallback` /
`startContinuousTranslation` 走 `_ensureInitialized`——先等在途初始化,仍没成就**按上次参数**补一次,**永远不用默认值**。
- `translation_controller.dart`:`_initializeCallModeTranslationService` 串行链 `_callInitChain`;`isPreparing` 期间忽略重复点开始;
`_attachBesCallTranslationBridge` 开桥前 `await _callInitChain`(10 s 上限)。
- `AzureSpeechPlugin.kt`:被新一代取代时打 `AST 初始化 gen=N … 被新一代取代,本次作废`,A/B 任一失败打结果——排查先找这两行。
修后 K90 连开 9 场(it/de/en/ja/fr/ko×2/en)全部阿里 3.5、初始化各 1.2 s、87 句译文、0 错误。
### 通话翻译的 PCM 已下沉到原生:bluetooth_manager ⇄ azure_speech 直连(2026-09-20) ### 通话翻译的 PCM 已下沉到原生:bluetooth_manager ⇄ azure_speech 直连(2026-09-20)
苹果测试反馈「WiFi 下断断续续、长句基本不行」正是此前记录的技术债触发条件(Dart 链路每秒约 200 次跨界, 苹果测试反馈「WiFi 下断断续续、长句基本不行」正是此前记录的技术债触发条件(Dart 链路每秒约 200 次跨界,
@ -1603,6 +1655,27 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
- 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 - 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应
品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。
### 版本更新判定 2026-09-23 口径:按平台分版本、最低版本留空=线上版本、渠道未上架不推、地址回退链
在下一节(8-31)的基础上改的,判定仍全在服务端 `evalChannelUpgrade`([api_getchannelapp.go](apps/services/modules/user/api_getchannelapp.go)),
客户端只拿结论。规则:
1. **Android / iOS 各一套版本号**:`app_release` 新增 `ios_version / ios_min_version`(启动 `ALTER ... ADD COLUMN IF NOT EXISTS` 幂等),
`version / min_version` 从此只管 Android。平台由渠道号推导(`comm.PlatformOfChannel`:只有 GlobalApple=101 是 iOS)。
⚠️ **iOS 没填时不回退 Android 的**——回退等于又回到共用一个版本号的老问题(Android 先发 1.0.3、iOS 商店还是 1.0.2,
iOS 用户被指向一个商店里没有的版本)。iOS 那栏留空 = iOS 不提示任何更新。
2. **最低支持版本留空 = 线上版本**(`MinVersionFor`):勾上强更不填最低版本,就是"所有低版本都强更";填了则只强更低于它的,
介于两者之间的仍是推荐。后台不再要求"开强更必填最低版本",改为"至少填一个平台的线上版本"。
3. **渠道行状态不是「已上架」(审核中/已下架)时不推送推荐更新**:商店里还是旧包。强更不受此限。
4. **下载地址回退链**(`comm.AppRelease.ResolveDownloadAddress`):Android 渠道 = 本渠道(须已上架)→ 官方渠道(100) → 默认下载地址;
**iOS / Google 只认本渠道**,没有就空——那两处如今放的是 apk 直链。
5. **算不出任何地址时两种更新都不提示**,强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。
客户端三处配套(`version_update_service.dart`):推荐更新的「稍后提醒」记版本号,同一版本 24 小时内冷启动不再弹(`snoozeVersion / isSnoozed`,
强更与设置页手动检查不受影响);设置页手动检查 `checkForUpdate(bypassCache: true)` 绕过 10 分钟缓存;Android 上服务端地址打不开时
回退网页下载页 `dl.ymaikj.com`(iOS 不兜底)。判定的金测试:`comm/apprelease_test.go`、`modules/user/module_test.go` 的
`TestEvalChannelUpgradePlatformStatusAddress` / `TestToPbChannelAppAddressFallbackChain`。
### 版本更新与强更判定(2026-08-31 改) ### 版本更新与强更判定(2026-08-31 改)
冷启动一定会查版本:splash 的 `_performParallelTasks` 顺序是 冷启动一定会查版本:splash 的 `_performParallelTasks` 顺序是
@ -1622,8 +1695,28 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
`comm.CompareVersion` 逐段按整数比 + 取前导数字的口径**逐行对齐** `comm.CompareVersion` 逐段按整数比 + 取前导数字的口径**逐行对齐**
(`test/version_compare_test.dart` 与 `comm/channelapp_test.go` 同一组用例守着)。 (`test/version_compare_test.dart` 与 `comm/channelapp_test.go` 同一组用例守着)。
原实现用 `int.parse` 解析,遇到 `1.0.0-beta` 直接抛,被外层 catch 吞成「无更新」。 原实现用 `int.parse` 解析,遇到 `1.0.0-beta` 直接抛,被外层 catch 吞成「无更新」。
- 服务端另有 `user_getchannelapp`(**单数**)会把 `needupdate/forceupdate/reviewing` - ⚠️ **客户端走的是 `user_getchannelapp`(单数)**,2026-09-23 核实:全仓唯一调用点是
三个结论算好下发,客户端目前没用它,仍走复数接口自己算——所以两边口径必须一致。 [version_update_service.dart:144](apps/client/lib/data/services/version_update_service.dart#L144);
`Api.getChannelApps`(复数)**定义了但零调用**。此前这里记反了。
单数接口把 `needupdate/forceupdate/reviewing` 三个结论在服务端算好下发,客户端直接用,
不再自己比版本号。复数接口目前只有下载落地页(dl.ymaikj.com)在用。
### 后台入口合并:「版本与分发」(2026-09-23)
「服务配置 → 版本控制」与「SaaS 管理 → 渠道分发」合成了一页 `/appdist`
([appdist.vue](apps/admin/app/pages/appdist.vue)),上半区发版配置、下半区渠道分发,
顶部一个应用选择器共用。页面本体拆成 `components/AppReleasePanel.vue` 与
`components/ChannelAppPanel.vue`;`/appversion`、`/channels` 只留重定向。
⚠️ **合的是入口,不是表**。`app_release` 与 `channel_app` 维度不同(见下一节),
两块各读各的接口、各自提交。**绝不能做成一个大表单整包保存**——`app_release` 那一行
还被「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组 → 整行写回」,
整包提交会在本页保存时清空游客登录配置。
⚠️ **菜单 id 仍是 `appversion`**(只改了 label 与 to)。存量后台账号的 access 清单里存的是
这个串,改 id 等于让这些账号丢入口。只勾过旧 id `channels` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容放行——同
「应用环境配置 → 服务与环境配置」那次的处理。
### 配置分家:channel_app 与 app_release(2026-08-31) ### 配置分家:channel_app 与 app_release(2026-08-31)
@ -1716,6 +1809,26 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
- 广播里的 MAC 是厂商数据 CID 之后 6 字节**原样**(`05 85 a9 1a b3 21 e4 dd 00`),协议文档那套 - 广播里的 MAC 是厂商数据 CID 之后 6 字节**原样**(`05 85 a9 1a b3 21 e4 dd 00`),协议文档那套
26 字节/XOR 0x5A 布局这台固件没发。 26 字节/XOR 0x5A 布局这台固件没发。
### EaiCar 设备设置面板:按能力出现、按设备回的 items 渲染(2026-09-22)
「设备 → 设备管理」里 EaiCar 那一行整块可点(解绑按钮除外),弹底部面板拉 `0x15` 显示并可改:
启停 `0x0D` / 音量 `0x0B` / 亮度 `0x0A` / 行车感应 `0x11` / 摇摆 `0x14` / 随机表情频次 `0x13` / 随机播一个表情 `0x06`。
设计与指令对照见 [docs/EaiCar设备控制面板-落地文档.md](docs/EaiCar设备控制面板-落地文档.md)。
- **面板是厂商无关的**(`lib/modules/my_devices/views/widgets/device_settings_sheet.dart`):只认
`DeviceCapability.settingsPanel` 与 `DeviceFeatures.settings*` 通用键,按 `settings.query` 返回的 `items`
画控件,缺哪项不画哪项。EaiCar 插件把通用键映射成字节(`EaiCarSession.settingsCommand`,纯函数,
`test/devices/eaicar_settings_features_test.dart` 逐字节钉死)。以后耳机/支架要露设置,各自插件声明即可。
- **状态只有 `0x15` 一个来源**:六条写命令的应答只回 `0x64` 成功码,不回显新值,也没有主动上报。
插件写成功后**本地回填**并派 `settings.changed`;面板从不自己推断状态。原先 `_applySummary`
解完 14 字节只留固件和电量、其余全丢,这次整份留下(`EaiCarSession.settings`)。
- `0x11` 双向语义不同:`AA 11` 是行车感应开关,`CC 11` 是设备上报 AI 唤醒,按帧头分发,别把后者当开关状态。
- 「随机表情」设备只有**频次**(秒数原值,9 档);"现在随机播一个"没有原生命令,由 app 在 0x00~0x09 里挑一个发 `0x06`(单向)。
- 未连接的 EaiCar 行也可点(按设备名猜品类 `DeviceCategories.categoryIdForName`),进去是「未连接 / 去连接」;
其它品类的行 onTap 为 null,行为与以前一字不差。
- ⚠️ **`0x0D` 真机验证口径**:lumi 常量叫"自动开关机",第一次发之前先在 `0x15` 看当前值、发同值确认应答,再翻转,
别一上来发 0。`0x14`/`0x11` 翻转后回读 `0x15` 对应字节确认。
### iOS 在 Xcode 里构建报「requires minimum platform version 16.0 ... but this target supports 13.0」 ### iOS 在 Xcode 里构建报「requires minimum platform version 16.0 ... but this target supports 13.0」
**不是代码问题,是 Flutter 的 SPM 集成和 Xcode 的构建顺序对不上。跑 **不是代码问题,是 Flutter 的 SPM 集成和 Xcode 的构建顺序对不上。跑
@ -1772,20 +1885,55 @@ Flutter 自己有补救 —— `SwiftPackageManager.updateMinimumDeployment()`
自有服务器域名**一律 `ymaikj.com`**,上一代的 `voitrans.net` / `voitrans.com` / `ideapsound.com` / 自有服务器域名**一律 `ymaikj.com`**,上一代的 `voitrans.net` / `voitrans.com` / `ideapsound.com` /
`deapsound.com` 已全部清除(含 41 个语言文件里用户可见的 `web.voitrans.com`)。现役清单: `deapsound.com` 已全部清除(含 41 个语言文件里用户可见的 `web.voitrans.com`)。现役清单:
| 用途 | 域名 | 位置 | **已通的服务器域名**(NPM 反代,两台阿龙机):
| 用途 | 测试机 8.133.166.29 | 正式机 47.116.104.181 |
|---|---|---| |---|---|---|
| 业务后端 | `ym-dev.ymaikj.com` | `.env` 的 `SERVER_URL`/`HW_SERVER_URL` | | 业务网关 gateway HTTP(7100) | `ym-dev.ymaikj.com` | `ym.ymaikj.com` |
| 支付回调 | `pay.ymaikj.com` | `pay_service.dart`、`goods_base_controller.dart` | | 管理后台 admin(3000) | `console-dev.ymaikj.com` | `console.ymaikj.com` |
| 会议分享 | `share.ymaikj.com` | `share_bottom_sheet.dart` | | MCP HTTP/SSE(7300) | `mcp-dev.ymaikj.com` | `mcp.ymaikj.com` —— DNS 就绪,**反代与证书仍未建** |
| Web 站点 | `web.ymaikj.com` | `member_bottom_sheet.dart`、`meeting_mine_view.dart`、各语言文案 | | 镜像仓库 | `registry.ymaikj.com`(实体在测试机上) | 同一个 |
| 微信 Universal Link | `ymaikj.com` | `login_service.dart`、`pay_service.dart`、`ios/Runner/Runner.entitlements` |
| MCP(死配置) | `mcp.ymaikj.com`、`api.ymaikj.com` | `.mcp.json` | **客户端辅助域名**——⚠️ 四个全部解析到测试机,且测试机 NPM 上**没有它们的反代规则**,
也就是现在一个都打不开(2026-09-11 实测,见 [上线落地文档 §8.4](docs/2026-09-11-阿龙测试服上线正式服-落地文档.md)):
⚠️ **微信 Universal Link 改了代码还不够**,三处必须完全一致,否则微信登录与微信支付**静默失败**:
① `universalLink: 'https://ymaikj.com/app/'`(两个 dart 文件) | 域名 | 用途 | 什么时候必须处理 |
② `Runner.entitlements` 的 `applinks:ymaikj.com` |---|---|---|
③ **微信开放平台后台登记的 Universal Link** + `https://ymaikj.com/.well-known/apple-app-site-association` | `pay.ymaikj.com` | 支付回调。硬编码在 `pay_service.dart`(3 处)、`goods_base_controller.dart`(2 处)。⚠️ `notify_url` 是**客户端下单时传给服务端的**,不是服务端配置 | 正式收款前 |
代码侧(①②)已改完,**③ 属于外部系统,发版前务必确认已同步**。 | `share.ymaikj.com` | 会议分享落地页。**2026-09-23 已落地**(`starpivot-app:0.2.5`):页面 `go:embed` 在 gateway 里,路由 `/share/*any`。DNS 已指向正式机、白名单已加,**只差 NPM 反代那一条规则** | 配完 NPM 即可用 |
| `web.ymaikj.com` | EAIMAR 用户侧 Web。`member_bottom_sheet.dart` 有真实入口,41 个语言文案里都印了 | 做 Web 版时 |
| `dl.ymaikj.com` | 下载落地页。页面已写好([deploy/dl/](deploy/dl/),含 OSS 部署教程),**桶与域名尚未建**。版本号/更新日志/各商店链接实时取自后台 `user_getchannelapps`,图标与 App 同一份素材 | 对外发链接前 |
**非服务器域名**:`oss.ymaikj.com` → 阿里云 OSS 自定义域名(桶 `ymaioss`,上海,测试/正式共用,
不指向任何业务机);`mcp.ymaikj.com:8000` / `api.ymaikj.com` 在 `.mcp.json` 里是死配置。
⚠️ **微信 Universal Link 用的是 `yimai.games`,不是 `ymaikj.com`**(这一行以前记错过)。
三处必须完全一致,否则微信登录与微信支付**静默失败**:
① `universalLink: 'https://yimai.games/app/'`(`login_service.dart`、`pay_service.dart`)
② `Runner.entitlements` 的 `applinks:yimai.games`
③ **微信开放平台后台登记的 Universal Link** + `https://yimai.games/.well-known/apple-app-site-association`
代码侧(①②)是对的,**③ 属于外部系统,发版前务必确认已同步**。
### `com.saitong.voitrans` 是错值,不只是遗留串(2026-09-23 修)
iOS 工程的真实 bundle id 是 **`com.yimai.eaimar`**(`Runner.xcodeproj` 的
`PRODUCT_BUNDLE_IDENTIFIER`),而 `home.yaml` 的 `appleiap.AppStoreBundleID` 一直写着
上一代的 `com.saitong.voitrans`。[appleiap.go](apps/services/sys/pay/appleiap/appleiap.go)
拿它做两件事:签 App Store Server API 的 JWT(载荷 `bid`)、校验交易归属
(`info.BundleID` 不相等就拒)。**对不上 = 所有 iOS 内购校验都失败**,报
「交易归属的 bundleId 不匹配」。模板与三处测试已改,⚠️ **服务器上的实际值要单独确认**——
这个字段是 `comm.ModuleCatalog` 托管的(`Required: true`),后台「支付 → Apple 内购」可改,
库里那行很可能是当初从模板 seed 进去的错值。
⚠️ **`GoogleService-Info.plist` 里的 `BUNDLE_ID` 同样是 `com.saitong.voitrans`**(三份副本:
`apps/client/`、`ios/Runner/`、`assets/config/`,内容完全一致)。**这个不能直接改**——
plist 是从 Firebase 控制台下载的,BUNDLE_ID 要与那边登记的 iOS 应用一致;本地改字符串而
不在 Firebase 里注册 `com.yimai.eaimar`,只会从「登记的是旧 id」变成「根本对不上」。
要正的话得在 Firebase 控制台加一个 iOS 应用再重新下载三份。`PROJECT_ID: voitrans` 同理。
⚠️ Redis 的 `KeyPrefix` 兜底默认值已从 `Voitrans` 改成 `EAIMAR`(与 `env.example` 一致)。
**不能删成 `${REDIS_KEY_PREFIX:-}`**:`lego/sys/redis` 的 `RKey` 在前缀为空时**直接返回裸 key**,
而阿龙两台机上同一个 Redis 跑着多个应用,无前缀 = 互相踩。
⚠️ 另外两处含 `voitrans` 但**不是域名、不要改**: ⚠️ 另外两处含 `voitrans` 但**不是域名、不要改**:
- `lib/firebase_options.dart` 的 `projectId: 'voitrans'` / `'voitrans-e0e3d'` —— Firebase 项目标识, - `lib/firebase_options.dart` 的 `projectId: 'voitrans'` / `'voitrans-e0e3d'` —— Firebase 项目标识,
@ -1793,8 +1941,30 @@ Flutter 自己有补救 —— `SwiftPackageManager.updateMinimumDeployment()`
- `lib/core/translations/` 里的 key 名(`copyrightVoitrans`、`voitransPrivateCloud`…)—— 是标识符不是文案, - `lib/core/translations/` 里的 key 名(`copyrightVoitrans`、`voitransPrivateCloud`…)—— 是标识符不是文案,
用户不可见,改了要 41 个语言文件连同引用处一起动。 用户不可见,改了要 41 个语言文件连同引用处一起动。
`share_bottom_sheet.dart` 的分享链接格式是 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token}`, ### 会议纪要分享(2026-09-23 落地,详见 [上线步骤](docs/会议纪要分享-落地与上线步骤.md))
**路径第二段是品牌标识、不是要请求的域名**,由 share 端解析,别当成域名误改。
链接格式 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}`。
`serverHost` 取自 `DioManager().baseUrl`,告诉落地页去哪个后端取数据;
**第二段 `ymaikj.com` 是品牌标识、不是要请求的域名**,别当成域名误改。
⚠️ **最后一段以前放的是 `logintoken`(完整登录 JWT)**,谁拿到链接就拿到了整个账号且无法失效。
现在是服务端签发的分享票据([api_sharelink.go](apps/services/modules/echomeet/api_sharelink.go)):
HMAC 签名、绑定单条记录、只读、默认 7 天过期,密钥由 `FIELD_ENCRYPT_KEY` 派生(上线不需要新配置项)。
**别改回去。**
三处各自的坑:
- `echomeet_getsharedrecord` **必须在 gateway `WhiteList` 里**——打开链接的人本来就没登录。
漏了是 `code:18`,表现为「分享链接打开一片空白」。签发端 `echomeet_createsharelink` 反之,
**不要**加进白名单。
- 落地页嵌在 gateway 二进制里(`modules/gateway/share_page.html` + `go:embed`),
路由 `GET /share/*any` **必须是 catch-all**:路径里的段是数据不是文件路径,
`gin.Static` 会去磁盘找同名文件、一律 404。
- 落地页里的 `ALLOWED_HOSTS` 白名单不能删:`serverHost` 来自 URL,不校验就等于
「构造一条链接让别人的浏览器带着票据去请求任意域名」。新增环境要加一行。
⚠️ 落地页那个极简 Markdown 渲染器**先 `esc()` 再套正则**,所以块级语法要匹配转义后的形态——
引用块匹配的是 `&gt;` 不是 `>`。写成 `>` 不会报错,只会让引用块静默退化成普通段落(踩过)。
### 其他 ### 其他
@ -2140,6 +2310,57 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
⚠️ `rootDir` 参数现在是 scene,**不要再自己拼 `User/<uid>/` 前缀**(agent 图片那处 ⚠️ `rootDir` 参数现在是 scene,**不要再自己拼 `User/<uid>/` 前缀**(agent 图片那处
原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。 原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。
### 更新入口:一个「网页下载页」兜住所有渠道(2026-09-23)
**App 自己不做品牌路由,下载页做。** 链路:
```
客户端按品牌推导渠道(getAndroidStoreInfoByBrand)
→ user_getchannelapp{channel, version}
→ 服务端:更新判定只看 app_release(与渠道无关);地址 = 渠道填了用渠道的,
没填回退 app_release.address(AppRelease.DownloadAddress)
→ performUpdate 打开这个地址
```
所以后台「版本控制 → 默认下载地址」填 `https://dl.ymaikj.com` 之后,**任何渠道没单独配地址
都会落到网页下载页**,再由那个页面按品牌跳商店 / 认不出就给安装包。要让某家商店直达,
才去「渠道分发」给那条填商店链接。
- 这套**不需要新增字段**:`app_release.address` 的语义本来就是「渠道没配时的兜底」,
而下载页正是兜底入口。
- 渠道行不存在**不影响更新提示**:`evalChannelUpgrade` 只读 `app_release`。
⚠️ 客户端只有在 `app_release` 也没配时才会拿到 `app:null` 并按「无更新」处理。
- 后台「上传 APK」传完写进**渠道分发 → 官方渠道(100)** 的 address(网页下载页的安装包直链取它),
**同时把直链填进「默认下载地址」输入框**(2026-09-23 用户要求:传完要在框里看到链接,且这个地址
就作为官方包的下载地址;填进表单后仍要点「保存」才落库)。要让默认地址回到网页下载页,手动改回即可。
⚠️ `savechannelapp` 是**整行覆盖**,所以那段必须「读整行 → 只改 address → 写回」;
而且 `getchannelapps` 回的是 `{items:[...]}` **不是裸数组**,当成数组取会永远走新建分支,
把官方渠道已有的上架状态/支付渠道清零,且不报任何错。
- ⚠️ **超管传安装包报「必须先选择应用(请求缺少 X-App-Id)」,而页面上明明选了**(2026-09-23 测试服踩到):
`X-App-Id` 头来自登录态的 `currentAppId`,只有绑定了应用的运营/代理账号才有;超管在「版本与分发」页顶部
选的应用只是页面里的一个 `app_name` 字符串,从不进请求头。现在 `uploadFile` 把页面选的应用名放进
`getcostoken` 请求体的 `app_name`,服务端没有 `X-App-Id` 时按 name / app_name 反查注册表补作用域
(`getAppByNameOrAppName`)。主桶那条(固件/产品图)不受影响,它不按应用分目录。
### 分发桶:App 安装包与录音/固件分开放(2026-09-23)
`comm.AliyunOSSConf` 多了 `DistBucket` / `DistDomain`(对应【第三方服务配置 → 存储】的
`dist_bucket` / `dist_domain`,留空用 `comm.DefaultDistBucket`/`DefaultDistDomain`
= `ymai-dl` / `https://dl.ymaikj.com`)。凭据与主桶共用,`conf.ForDist()` 取副本。
后台「版本控制」页的 **上传 APK** 按钮走 `api_getcostoken` 带 `target:"dist"`:
- key **不加 `EAIMAR/` 根前缀**(分发桶直接挂在 `dl.ymaikj.com` 上,加了会让下载地址多一段),
目录只允许 `comm.DistUploadDirs` 白名单里的(当前只有 `android`)。
边界由 `TestBuildDistObjectKey` 钉死——这个桶是公共读的,key 拼错等于公开网盘。
- ⚠️ **Content-Type 由服务端按扩展名强制**(`distContentTypes`),并**回带给前端**:
浏览器对 `.apk` 给出的 `File.type` 往往是空字符串,前端照它发 PUT 会与预签名不符,
OSS 回 403 SignatureDoesNotMatch,而报错里完全看不出是类型对不上。
- ⚠️ **分发桶必须单独配 CORS**(来源 `https://console.ymaikj.com`、方法 PUT)。
浏览器直传打的是桶的 OSS endpoint 而不是自定义域名。没配时 XHR 只报一句没有细节的
「网络错误」(浏览器刻意不暴露跨域失败详情),所以 `uploadFile` 的 onerror 文案
直接点名了这个原因。主桶 `ymaioss` 早已配好,`ymai-dl` 2026-09-23 实测还是 403。
⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个 ⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个
`COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。 `COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。
@ -2148,6 +2369,38 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 + 所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 +
播放时也签临时读 URL,客户端所有播放入口都要改,还没做。 播放时也签临时读 URL,客户端所有播放入口都要改,还没做。
### 腾讯云 COS 服务端整体下线(2026-09-23)
服务端**不再有任何腾讯云 COS 代码**:`sys/tencentyun/` 整个删除,home / api 两个服务的
`cos.OnInit` 与 `Sys["cos"]` 配置段一并去掉,`comm.svcRuntimeSections` 也不再托管 `cos`。
- **头像上传改走 OSS**:`modules/user/api_setting.go` 的 `cos.Put` → 本文件里的 `putAvatar`,
配置复用 `comm.LoadAliyunOSSConf`(与 user_getuploadurl / console 后台直传同一个桶)。
object key 是 `EAIMAR/User/<uid>/Avatar/<uid><ext>`,**不带随机段**,即换头像覆盖同一对象
(与 COS 时期行为一致,代价是 URL 不变、客户端可能吃缓存)。扩展名过 `uploadExtWhitelist`
且必须是 `image/*`。
- `modules/api/api_getcostoken.go`(腾讯云 STS)**已删**——它是孤儿:后台 admin 调的
`getcostoken` 其实落在 console 的 [api_upload.go](apps/services/modules/console/api_upload.go)
(签阿里云 OSS 预签名),路由名只是沿用;客户端从不调它。
- 后台「系统配置 → 对象存储」与「第三方服务配置 → 存储」里的**腾讯云 COS 卡片/模板已删**,
巡检的 `probeTencentCOS` 也删了。理由:服务端读存储配置的唯一入口 `comm.LoadAliyunOSSConf`
只认 `provider=aliyun_oss`,留着 COS 这一项是「配得上、却不生效」的陷阱。
**存量 `tencent_cos` 行不会被自动删除**,只是不再有读取方。
⚠️ **老客户端仍读业务库 `config` 表里的 `COS_*` 直传腾讯云,那 5 个键还是别删**——
服务端下线的是自己这一侧,桶还在、老包的上传路径没断。历史录音的 `audiourl` 是 COS
绝对地址,同样不会自动搬家。
### 音乐服务整体下线(2026-09-23)
`sys/music`、`sys/musicobj`、`modules/music`、`modules/mcp/tool_music.go` 全部删除,
home 的模块列表与 `musicobj` 初始化、home/mcp 两份 yaml 模板里的 `musicobj` 段一并去掉。
起因是它的 `ApiBaseUrl` 指向上一代品牌域名(`hw.music.voitrans.net` / `hw.music.ideapsound.com`)。
客户端的音乐模块本来就早已删除。
⚠️ **咪咕音乐(`tool_migu_music` + `migu` 服务)没有动**,别一起当成音乐链路删了。
`pb` 里的 Music* 消息保留(生成物,改要动 .proto 重生成),现在没有读取方。
## 配置与密钥约定(重要) ## 配置与密钥约定(重要)
- 各 `conf/*.yaml` 与 `deploy/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。 - 各 `conf/*.yaml` 与 `deploy/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。

2
apps/client/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string> <string>com.yimai.eaimar</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>voitrans</string> <string>voitrans</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>

2
apps/client/assets/config/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string> <string>com.yimai.eaimar</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>voitrans</string> <string>voitrans</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>

388
apps/client/docs/face_to_face_ble_protocol.md

@ -1,27 +1,34 @@
# 面对面翻译 · 独立 BLE 链路协议(草案 v0.1) # 面对面翻译 · 双耳独立 BLE 链路协议(草案 v0.2)
> 状态:**待固件确认**。本文只定义协议,不含实现。 > 状态:**待固件确认**。本文只定义协议,不含实现。
> 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。 > 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。
> >
> 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有 SPP 协议,本文与它并存,不替代) > 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有经典蓝牙协议,本文与它并存,不替代)
>
> **v0.2 相对 v0.1 的核心变化**:产品形态定为「两个人各戴一只耳机」,TWS 拆成两只独立的 BLE 外设,
> 各自一条链路连 App。v0.1 的「档位 A 主耳中转」与 `TOPO` 自适应字段整体删除。
> 另外吸收了 2026-09-20 ~ 09-23 通话翻译真机定性的一批结论(写类型、调速语义、看门狗、音频路径),见 §16。
--- ---
## 0. 为什么要单开一条 BLE ## 0. 产品形态与为什么要单开 BLE
面对面翻译要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同。 **形态:A、B 两个人各戴一只耳机,一副耳机拆成两只独立设备,各自一条 BLE 连到同一部手机。**
A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到 B 那只耳机;反向同理。
手机只做中枢,不参与收音和放音(手机侧 UI 只显示字幕)。
现有恒玄链路是**一条经典蓝牙 SPP**(RFCOMM,UUID `aeaf5241-5453-7070-7353-445542594c46`), 这要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同,且:
所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。
这套结构服务「通话翻译」够用(两路的语义是固定的:本端 mic / 对端 spk),
但面对面的两路是**两个人**,需要:
- 两路上行**同时并发**且可独立起停(A 说话时 B 插话,不能排队); - 两路上行**同时并发**、可独立起停(A 说话时 B 插话,不能排队);
- 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵); - 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵);
- 每路带**独立序号**,好判丢包、好做打断。 - 每路带**独立序号**,好判丢包、好做打断;
- 两只耳机之间**不需要同步**:各听各的译文,没有立体声对齐要求。这一点让拆 TWS 变得可行。
在 SPP 的定长双槽结构上硬塞这些语义,会把现有通话翻译的包格式一起改掉。 现有恒玄链路是**一条经典蓝牙**(Android 是 SPP/RFCOMM,iOS 是 GATT over BR/EDR,见 §1.1),
所以本功能**另起一条 BLE 链路**,与 SPP 完全隔离:进面对面时建链,退出时拆链并恢复原状。 所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。
它服务「通话翻译」够用(两路语义固定:本端 mic / 对端 spk),但**副耳根本没有到手机的链路**,
面对面的两路是两个人、两只耳机,在这条链路上做不出来。所以另起 BLE:进面对面时拆 TWS、两耳各自建链,
退出时拆链并重新组对,恢复原状。
--- ---
@ -29,139 +36,141 @@
| 不动 | 说明 | | 不动 | 说明 |
|---|---| |---|---|
| 现有 SPP 全部指令与音频格式 | `AA 51/52/56/57/58/59/5A/5E/5F/65/69/6A`、`BB D1/D2/D9/DA/E5/E6/E7/64/55/68/60/5B` 一律不改 | | 现有经典蓝牙全部指令与音频格式 | `AA 51/52/56/57/58/59/5A/5E/5F/65/69/6A`、`BB D1/D2/D9/DA/E5/E6/E7/64/55/68/60/5B` 一律不改 |
| 现有 `AA 66` / `AA 67` 面对面 | 那是**旧版**面对面(手机麦上行 + `AA 69` 单声道下行),保留可用,新模式用新命令号,两者互斥但互不修改 | | 现有 `AA 66` / `AA 67` 面对面 | 那是**旧版**面对面(手机麦上行 + `AA 69` 单声道下行),命令号保留,App 侧当前也没有调用方;新模式用新命令号,两者互斥但互不修改 |
| 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们**互斥**,不并行 | | 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们**互斥**,不并行 |
| 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(见 §11) | | 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(见 §11) |
App 侧对应载体:[face_to_face_ble_service.dart](../lib/data/services/face_to_face_ble_service.dart) ### 1.1 ⚠️ iOS 上现有链路不是 SPP
(接口已固化,本文补的是它 `TODO(固件)` 里缺的那部分)。
业务层 [translation_controller.dart](../lib/modules/translation/controllers/translation_controller.dart)
的 `FaceToFaceTrigger` / `activeSpeaker` 语义不变。
--- Android 上现有链路是 SPP(RFCOMM);**iOS 上是 GATT over BR/EDR**(CoreBluetooth API,
但底层是经典蓝牙,靠 `registerForConnectionEvents` 接管系统已配对设备,详见 CLAUDE.md
「iOS 上恒玄走的是 GATT over BR/EDR,不是 BLE」)。这意味着:
## 2. 总体拓扑:两档,协议同一套 - 面对面期间主耳要**同时维护一条 BR/EDR 链路(到手机,控制面)和一条 LE 链路(本协议)**,
副耳只有 LE 链路。能否共存是 §14 Q1 的一部分;
- §6.2 的连接间隔 / PHY / DLE 这些是 LE 概念,**只对本协议的两条新链路成立**,
别拿去套现有链路。
固件按自身能力选一档,**App 侧接口与业务逻辑完全相同**,靠握手里的 `TOPO` 字段自适应。 ### 1.2 App 侧载体
### 档位 A —— 单链路(主耳中转)· 建议先落地 v0.1 提到的 `face_to_face_ble_service.dart` **已在 2026-09-09 设备内核重构中删除**,不要再找它。
新链路的落点按现行分层:
``` | 层 | 落点 | 说明 |
BLE (1 条) |---|---|---|
手机 ────────────────► 主耳 ──耳间私有链路──► 副耳 | LE 链路 + 帧编解码 | `bluetooth_manager` 原生(Swift / Kotlin) | 扫描、连接、GATT、G.722、`BB 90`/`AA 91` 组拆包 |
控制/双路音频 按 EAR 字段分发 | 音频 | **原生直连** `azure_speech`(同 `CallPcmBridge` 的接缝写法) | 上行 PCM 不经 Dart;下行译文 PCM 由 `azure_speech` 直接喂回 `bluetooth_manager` |
``` | 设备契约 | `lib/devices/bes/` 的 `BesDeviceSession` | 声明能力(沿用 `DeviceCapability.faceToFace`),`AudioRoute` 加 `f2fEarL/f2fEarR` |
| 业务 | `translation_controller.dart` 的 `FaceToFaceTrigger.device` 分支 | 只收文本、状态、按键事件;`setActiveSpeaker` 语义不变 |
- 只有主耳做 GATT peripheral,两路音频在同一条 BLE 上用帧头的 `EAR` 字段区分; **理由**:这条链路有实时截止期(每 40ms 一包、四路并发),按 9-21 的口径必须下沉原生,
- 与现有 SPP 的双路交织同构,固件改动量最小,TWS 主从关系不用拆; Dart 只做没有截止期的状态机与展示(见 CLAUDE.md「音频链路继续下沉」)。
- 代价:副耳多一跳转发(实测应 < 20ms,需固件给实测值);耳间链路带宽要能承载一路上行 + 一路下行。
### 档位 B —— 双链路(左右耳各自独立)· 目标形态 ---
## 2. 拓扑:两只耳机各自独立
``` ```
BLE (2 条,互相独立) BLE (2 条,互相独立)
手机 ═══════════════► 左耳 手机 ═══════════════► 左耳(EAR=1)
═══════════════► 右耳 ═══════════════► 右耳(EAR=2)
─ ─ ─ ─ ─ ─ ─ ─► 主耳(BR/EDR,现有链路,只走 AA 6B / 6C 控制)
``` ```
- 左右耳各自广播、各自被 App 连接,各跑一条 GATT; - 进入面对面时固件**解除 TWS 主从**,左右耳各自广播、各自被 App 连接,各跑一条 GATT;
- 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R); - 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R);
- 延迟最低、两路彻底解耦;代价是要拆 TWS 主从,A2DP/HFP 期间必然中断(见 §14 Q1/Q2)。 - 两条链路彻底解耦,延迟最低;代价是**A2DP / HFP 期间必然中断**(见 §11.5 来电处理);
- **每条链路上只应出现自己耳位的 `EAR` 值**。帧里保留 `EAR` 字段是为了防御:
> **协议帧在两档下完全一致**。档位 B 时每条链路只应出现自己耳位的 `EAR` 值, 收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`(属 App 侧 bug)。
> 收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`。
--- ---
## 3. 完整时序 ## 3. 完整时序
``` ```
App 耳机(主耳) 副耳 App 主耳(BR/EDR) 左耳(LE) 右耳(LE)
│ │ │ │ ① 经典: AA 6B <ver><cap><token> │ │ │
│ ① SPP: AA 6B <ver><cap><topo><token> │ │ │─────────────────────────────────────►│ │ │
│───────────────────────────────────────►│ │ │ │ 停A2DP/HFP、拆TWS主从 │
│ │ 切工作模式:停A2DP/HFP│ │ ② 经典: BB EB <ok><earmask>... │ 两耳各起 LE 广播(带 token) │
│ ② SPP: BB EB <ok><topo><earmask>... │ 起 BLE 广播(带 token) │ │◄─────────────────────────────────────│ │ │
│◄───────────────────────────────────────│ │ │ ③ LE 扫描 → 按 token 过滤 → 集齐 EAR=1 与 EAR=2 → 分别连接 │
│ │ │ │ ④ 各链路:MTU 交换 / 2M PHY / 订阅通知 │
│ ③ BLE 扫描 → 按 token 过滤 → 连接 │ │ │─────────────────────────────────────────────────────►│────────────────►│
│ ④ MTU 交换 / 2M PHY / 订阅通知 │ │ │ ⑤ LE: AA 01 HELLO <token><role>(每条链路各一次) │ │
│───────────────────────────────────────►│ │ │ ⑥ LE: BB 81 HELLO_ACK <ear><fw>... │ │
│ │ │ │ ⑦ LE: AA 02 SESSION_CFG │ │
│ ⑤ BLE: AA 01 HELLO <token><role> │ │ │ ⑧ LE: AA 03 START_STREAM │ 开拾音 │ 开拾音
│───────────────────────────────────────►│ │
│ ⑥ BLE: BB 81 HELLO_ACK <ear><fw>... │ │
│◄───────────────────────────────────────│ │
│ ⑦ BLE: AA 02 SESSION_CFG │ │
│───────────────────────────────────────►│ │
│ ⑧ BLE: AA 03 START_STREAM │ │
│───────────────────────────────────────►│ 开双耳拾音 │
│ │ │
│ ⑨ BLE: BB 90 上行音频(EAR=L, seq++) │ │
│◄═══════════════════════════════════════│◄─────────────────────│
│ ⑩ 云端 ASR→翻译→TTS │ │
│ ⑪ BLE: AA 91 下行音频(EAR=R, seq++) │ │
│═══════════════════════════════════════►│─────────────────────►│ 播放
│ │ │ │ │ │
│ ⑫ BLE: AA 05 KEEPALIVE (1s 周期) │ │ │ ⑨ LE(左): BB 90 上行音频(EAR=1, seq++) │ │
│◄──────────────────────────────────────►│ │ │◄═════════════════════════════════════════════════════│ │
│ ⑩ 云端 AST(端到端) │ │
│ ⑪ LE(右): AA 91 下行音频(EAR=2, seq++) │ │
│═══════════════════════════════════════════════════════════════════════►│ 播放
│ │ │ │ │ │
│ ⑬ BLE: AA 04 STOP_STREAM │ │ │ ⑫ LE: AA 04 STOP_STREAM(每条链路) │ │
│───────────────────────────────────────►│ │ │ ⑬ LE: AA 06 EXIT(每条链路,见 §7)→ 耳机主动断开 │ │
│ ⑭ BLE 主动断开 │ │ │ ⑭ 经典: AA 6C 退出面对面(兜底,幂等) │ 恢复TWS/恢复A2DP │
│ ⑮ SPP: AA 6C 退出面对面 │ │ │─────────────────────────────────────►│ │ │
│───────────────────────────────────────►│ 停广播/恢复TWS/恢复A2DP│ │ ⑮ 经典: BB EC <ok> │ │ │
│ ⑯ SPP: BB EC <ok> │ │ │◄─────────────────────────────────────│ │ │
│◄───────────────────────────────────────│ │
``` ```
**为什么入口放在 SPP 而不是直接扫 BLE**:耳机平时不广播这个服务(省电、也避免被无关 App 扫到), **为什么入口放在经典链路而不是直接扫 BLE**:耳机平时不广播这个服务(省电、也避免被无关 App 扫到),
只有收到 `AA 6B` 才开始广播,且广播里带上 App 给的 token。 只有收到 `AA 6B` 才拆主从、开始广播,且广播里带上 App 给的 token。
这样「扫描时连到隔壁桌同型号耳机」这个问题在协议层就不成立(见 §5)。 这样「扫描时连到隔壁桌同型号耳机」这个问题在协议层就不成立(见 §5)。
**为什么退出要有两条路**:拆主从之后经典链路是否还活着,取决于固件(§14 Q1)。
所以 LE 上必须有自己的 `EXIT`(⑬),经典链路的 `AA 6C`(⑭)只是兜底与清状态。
任何一条到了,固件都要走 §11.2 的恢复清单。
--- ---
## 4. 阶段一 · SPP 侧模式切换 ## 4. 阶段一 · 经典链路侧模式切换
沿用现有 SPP 帧格式:`AA CMD [LEN] [payload]` / `BB CMD LEN [payload]`。 沿用现有帧格式:`AA CMD [LEN] [payload]` / `BB CMD LEN [payload]`。
命令号选在模式控制段(`0x5x`/`0x6x`)内的空位,响应码遵守本机固件 `响应 = 请求 | 0x80` 的规律。 命令号选在模式控制段(`0x5x`/`0x6x`)内的空位,响应码遵守本机固件 `响应 = 请求 | 0x80` 的规律。
> ⚠️ **命令号是 (产品, 固件版本) 的函数**(CLAUDE.md 同名一节)。下表与 `6B/6C` 只针对 **Echo-one** 这条固件线,
> 换产品要重新核对。
>
> 已占用下行:`06 09 0B 0C 0E 0F 10 20 51 52 56 57 58 59 5A 5E 5F 65 66 67 69 6A` > 已占用下行:`06 09 0B 0C 0E 0F 10 20 51 52 56 57 58 59 5A 5E 5F 65 66 67 69 6A`
> 已占用上行:`02 03 04 07 0A 18 55 5B 60 61 64 68 86 89 8C 8E 90 D1 D2 D6 D9 DA DE DF E5 E6 E7 E9` > 已占用上行:`02 03 04 07 0A 18 55 5B 60 61 64 68 86 89 8C 8E 90 D1 D2 D6 D9 DA DE DF E5 E6 E7 E9`
> 本协议新增 `6B/6C` ↔ `EB/EC`,与上表均无冲突。 > 本协议新增 `6B/6C` ↔ `EB/EC`,与上表均无冲突。
> >
> ⚠️ App 侧的 `BesBluetoothService.probeCommands()` 目前跳过的是 `0x51~0x6A`(会切工作模式的段), > ⚠️ App 侧 `BesCmd.switchesMode()` 与 `BesBluetoothService.probeCommands()` 目前跳过的是 `0x51~0x6A`,
> **落地本协议时必须把跳过区间扩到 `0x6C`** —— 否则扫命令时会把耳机扫进面对面模式, > **落地本协议时必须把区间扩到 `0x6C`** —— 否则扫命令时会把耳机扫进面对面模式并拆掉 TWS,
> 且扫描进程不会发 `AA 6C` 退出,只能靠看门狗兜底。 > 且扫描进程不会发退出,只能靠看门狗兜底。
### 4.1 `AA 6B` 进入面对面 BLE 模式 ### 4.1 `AA 6B` 进入面对面 BLE 模式
``` ```
AA 6B 07 <PROTO_VER> <CAP> <TOPO_REQ> <TOKEN 4B> AA 6B 06 <PROTO_VER> <CAP> <TOKEN 4B>
``` ```
| 字段 | 长度 | 取值 | | 字段 | 长度 | 取值 |
|---|---|---| |---|---|---|
| `PROTO_VER` | 1 | 本文档版本,首版 `0x01` | | `PROTO_VER` | 1 | 本文档版本,`0x02` |
| `CAP` | 1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 `0x01` | | `CAP` | 1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 `0x01` |
| `TOPO_REQ` | 1 | `0x00`=由耳机决定(推荐)、`0x01`=要求单链路、`0x02`=要求双链路 | | `TOKEN` | 4 | App 生成的随机会话标识(`crypto` 级随机),每次进入重新生成,用于广播过滤 |
| `TOKEN` | 4 | App 生成的随机会话标识,每次进入重新生成,用于广播过滤 |
(v0.1 的 `TOPO_REQ` 已删除:形态只有一种。)
### 4.2 `BB EB` 响应 ### 4.2 `BB EB` 响应
``` ```
BB EB 08 <RESULT> <TOPO> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
``` ```
| 字段 | 取值 | | 字段 | 取值 |
|---|---| |---|---|
| `RESULT` | 见 §12 错误码,`0x00`=已进入并开始广播 | | `RESULT` | 见 §12 错误码,`0x00`=已拆主从并开始广播 |
| `TOPO` | `0x01`=单链路(仅主耳广播)、`0x02`=双链路(左右耳各自广播) | | `EAR_MASK` | bit0=左耳可用、bit1=右耳可用。**两位必须都置**,否则固件应直接回 `ERR_EAR_UNAVAILABLE`(面对面需要双耳,单耳在盒/未佩戴不进入) |
| `EAR_MASK` | bit0=左耳可用、bit1=右耳可用。**单耳在盒时只置一位**,App 据此提示用户 |
| `CODEC` | 实际采用的编码,`0x01`=G.722 | | `CODEC` | 实际采用的编码,`0x01`=G.722 |
| `FRAME_MS` | 每包承载的音频时长,`20` 或 `40`(建议 40) | | `FRAME_MS` | 每包承载的音频时长,`20` 或 `40`(建议 40) |
| `FW_MINOR` | 固件的本协议实现小版本,便于后续差异化兼容 | | `FW_MINOR` | 固件的本协议实现小版本,便于后续差异化兼容 |
| `CLASSIC_KEEP` | `0x01`=拆主从后经典链路仍保持;`0x00`=经典链路将断开,App 退出只能走 LE `EXIT`(§7) |
### 4.3 `AA 6C` 退出 ### 4.3 `AA 6C` 退出
@ -176,12 +185,12 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
## 5. 阶段二 · BLE 广播与发现 ## 5. 阶段二 · BLE 广播与发现
### 5.1 广播内容 ### 5.1 广播内容(左右耳各自广播)
| 字段 | 内容 | | 字段 | 内容 |
|---|---| |---|---|
| Flags | LE General Discoverable + BR/EDR Not Supported | | Flags | LE General Discoverable + BR/EDR Not Supported |
| Local Name | `<产品名>-F2F`,左右耳**可以同名**(靠 MSD 区分,不靠名字) | | Local Name | `<产品名>-F2F`,左右耳**同名**(靠 MSD 区分,不靠名字) |
| Service UUID (16B) | 见 §6.1 | | Service UUID (16B) | 见 §6.1 |
| Manufacturer Specific Data | 见下 | | Manufacturer Specific Data | 见下 |
@ -195,7 +204,7 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
|---|---| |---|---|
| `MAGIC` | 固定 `F2 F2`,快速筛掉无关广播 | | `MAGIC` | 固定 `F2 F2`,快速筛掉无关广播 |
| `VER` | 协议版本,与 `AA 6B` 的 `PROTO_VER` 一致 | | `VER` | 协议版本,与 `AA 6B` 的 `PROTO_VER` 一致 |
| `EAR` | `0x01`=左耳、`0x02`=右耳、`0x03`=主耳(单链路档,代表双耳) | | `EAR` | `0x01`=左耳、`0x02`=右耳 |
| `TOKEN` | **原样回显 `AA 6B` 里那 4 字节** | | `TOKEN` | **原样回显 `AA 6B` 里那 4 字节** |
| `BATT` | 该耳电量 0~100,低 7 位;最高位为充电位(与 `BB 0A` 同口径) | | `BATT` | 该耳电量 0~100,低 7 位;最高位为充电位(与 `BB 0A` 同口径) |
@ -204,14 +213,17 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
1. 只接受 `MAGIC` 命中的广播; 1. 只接受 `MAGIC` 命中的广播;
2. **`TOKEN` 必须与本次 `AA 6B` 下发的完全一致,否则一律忽略** —— 2. **`TOKEN` 必须与本次 `AA 6B` 下发的完全一致,否则一律忽略** ——
这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副; 这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副;
3. 档位 B 下需集齐 `EAR=0x01` 与 `0x02` 两个广播才算就绪;只集到一个时按 §14 Q9 的降级策略处理; 3. **必须集齐 `EAR=0x01` 与 `0x02` 两个广播才开始连接**;15s 内只集到一个 → 按 §5.2 第 4 条回滚,
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」。 提示「请确认双耳都已取出并佩戴」。不做单耳降级:单耳面对面在产品上不成立;
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」;
5. **扫描必须在 App 前台完成**:iOS 后台扫描会截断广播数据,MSD 里的 TOKEN 拿不到,第 2 条就无从判定。
扫描期间切后台 → 视为超时回滚。
### 5.3 广播参数与耳机侧超时 ### 5.3 广播参数与耳机侧超时
- 建链期快速广播:间隔 30~50ms,持续 30s; - 建链期快速广播:间隔 30~50ms,持续 30s;
- **30s 内无人连接,耳机自动退出面对面模式并恢复常态**(第一道看门狗,见 §11); - **30s 内无人连接,该耳自动退出面对面模式**;两耳都退出后重新组对、恢复常态(第一道看门狗,见 §11);
- 连接建立后立即停止广播(档位 B 下各耳独立停)。 - 连接建立后立即停止广播(各耳独立停)。
### 5.4 iOS 注意 ### 5.4 iOS 注意
@ -221,11 +233,11 @@ iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同
--- ---
## 6. 阶段三 · GATT 服务与特征 ## 6. 阶段三 · GATT 服务与特征(两耳完全相同)
### 6.1 UUID(**待固件分配,下表为占位**) ### 6.1 UUID(**待固件分配,下表为占位**)
沿用现有 SPP UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000` 沿用现有 UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000`
| 项 | 占位 UUID | 属性 | 方向 | | 项 | 占位 UUID | 属性 | 方向 |
|---|---|---|---| |---|---|---|---|
@ -237,46 +249,58 @@ iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同
**控制与音频分开成 4 个特征,不合并**:音频是每 40ms 一包的高频流, **控制与音频分开成 4 个特征,不合并**:音频是每 40ms 一包的高频流,
与控制帧共用一个特征时,控制消息(尤其是「打断」和 `STOP_STREAM`)会排在音频队列后面, 与控制帧共用一个特征时,控制消息(尤其是「打断」和 `STOP_STREAM`)会排在音频队列后面,
表现为「点了停止还在播」。分开后两条队列各自独立。 表现为「点了停止还在播」。9-21 在通话翻译上实测过同一个坑(控制命令排到积压音频后面 → 电量/版本全查不到),
分开后两条队列各自独立。
### 6.2 链路参数要求 ### 6.2 链路参数要求(每条 LE 链路各自满足)
| 项 | 要求 | 理由 | | 项 | 要求 | 理由 |
|---|---|---| |---|---|---|
| ATT MTU | ≥ 185,建议 247 | 40ms 聚合包 = 6B 头 + 80B 音频 = 86B;留余量给未来的 Opus/更长聚合 | | ATT MTU | ≥ 185,建议 247 | 40ms 聚合包 = 6B 头 + 80B 音频 = 86B;留余量给未来的 Opus/更长聚合 |
| PHY | 优先 2M,回退 1M | 四路并发时降低空口占用 | | PHY | 优先 2M,回退 1M | 两条链路并发时降低空口占用 |
| 连接间隔 | 15~30ms | 大于 40ms 会让下行音频出现可闻的断续 | | 连接间隔 | 15~30ms | 大于 40ms 会让下行音频出现可闻的断续 |
| Slave latency | 0 | 音频流不允许休眠跳包 | | Slave latency | 0 | 音频流不允许休眠跳包 |
| 数据长度扩展 (DLE) | 开 | 否则 86B 包会被拆成多个 27B 空口包,吞吐骤降 | | 数据长度扩展 (DLE) | 开 | 否则 86B 包会被拆成多个 27B 空口包,吞吐骤降 |
| Supervision timeout | 2~4s | 它就是 §11.1 的看门狗 2:App 被杀后链路在此时间内必断 |
### 6.3 App 侧写入规则(强制,9-21 真机定性)
1. `AUDIO_DOWN` 与 `CTRL_TX` **一律 Write Without Response**。带应答写每包要等 ATT 层 ACK 才能发下一包,
50 包/秒的实时流必然追不上;
2. iOS 的 `canSendWriteWithoutResponse` **只能作观测,不能作门槛**(拿它当闸门会死锁,见 CLAUDE.md「GATT 写入三条铁律」);
3. 控制命令**绝不排队**在音频后面,直发。
--- ---
## 7. 阶段四 · BLE 链路握手 ## 7. 阶段四 · BLE 链路握手(每条链路各自一遍)
BLE 链路上的操作码空间**与 SPP 独立**(不同链路,不会混淆)。 BLE 链路上的操作码空间**与经典链路独立**(不同链路,不会混淆)。
帧格式同样是 `AA <OP> <LEN> <payload>` / `BB <OP|0x80> <LEN> <payload>`,`LEN` 为 payload 字节数。 帧格式同样是 `AA <OP> <LEN> <payload>` / `BB <OP|0x80> <LEN> <payload>`,`LEN` 为 payload 字节数。
| 步骤 | 帧 | payload | 说明 | | 步骤 | 帧 | payload | 说明 |
|---|---|---|---| |---|---|---|---|
| ① HELLO | `AA 01` | `<VER 1B><TOKEN 4B><ROLE 1B>` | `ROLE`:`0x01`=App。token 再校验一次,防止连错 | | ① HELLO | `AA 01` | `<VER 1B><TOKEN 4B><ROLE 1B>` | `ROLE`:`0x01`=App。token 再校验一次,防止连错 |
| ② HELLO_ACK | `BB 81` | `<RESULT><EAR><FW 3B><CODEC><FRAME_MS><MTU_OK>` | `EAR` 必须与广播里一致;`MTU_OK`=0 时 App 降级到 20ms 单帧包 | | ② HELLO_ACK | `BB 81` | `<RESULT><EAR><FW 3B><CODEC><FRAME_MS><MTU_OK>` | `EAR` 必须与广播里一致;`MTU_OK`=0 时 App 降级到 20ms 单帧包 |
| ③ SESSION_CFG | `AA 02` | `<UP_ENABLE 1B><VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B>` | 见下 | | ③ SESSION_CFG | `AA 02` | `<VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B>` | 见下 |
| ④ CFG_ACK | `BB 82` | `<RESULT>` | | | ④ CFG_ACK | `BB 82` | `<RESULT>` | |
| ⑤ START_STREAM | `AA 03` | `<EAR_MASK 1B>` | 指定开哪几路上行;档位 B 下固定为本耳 | | ⑤ START_STREAM | `AA 03` | — | 开本耳上行。回 `0x00` 后耳机开始推 `BB 90` |
| ⑥ START_ACK | `BB 83` | `<RESULT>` | 回 `0x00` 后耳机开始推 `BB 90` | | ⑥ START_ACK | `BB 83` | `<RESULT>` | |
| ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) | | ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) |
| ⑧ KEEPALIVE | `AA 05` | `<SEQ 1B>` | App 每 **1s** 发一次 | | ⑧ KEEPALIVE | `AA 05` | `<SEQ 1B>` | App 每 **5s** 发一次,**只用于诊断**(§11.1),耳机不据此断链 |
| ⑨ KEEPALIVE_ACK | `BB 85` | `<SEQ 1B><LOSS 1B>` | `LOSS`=耳机侧统计的下行丢包率(%),用于诊断 | | ⑨ KEEPALIVE_ACK | `BB 85` | `<SEQ 1B><UP_LOSS 1B><DOWN_LOSS 1B>` | 丢包率(%),写日志 |
| ⑩ EXIT | `AA 06` | — | 本耳退出面对面:停流、断开本 LE 连接、走 §11.2 恢复。两耳都收到后重新组对 |
| ⑪ EXIT_ACK | `BB 86` | `<RESULT>` | 回完再断 |
**`SESSION_CFG` 字段** **`SESSION_CFG` 字段**
| 字段 | 取值 | | 字段 | 取值 |
|---|---| |---|---|
| `UP_ENABLE` | bit0=左耳上行、bit1=右耳上行 |
| `VAD_MODE` | `0x00`=**常发**(不做静音抑制,默认)、`0x01`=静音抑制(耳机侧 VAD 判静音则不发包) | | `VAD_MODE` | `0x00`=**常发**(不做静音抑制,默认)、`0x01`=静音抑制(耳机侧 VAD 判静音则不发包) |
| `DOWN_GAIN` | 下行播放增益 0~100,默认 80 | | `DOWN_GAIN` | 下行播放增益 0~100,默认 80 |
| `TRIGGER` | `0x00`=免按键(连续拾音)、`0x01`=按住说话(按键事件驱动,对应 App 的 `FaceToFaceTrigger.device`) | | `TRIGGER` | `0x00`=免按键(连续拾音)、`0x01`=按住说话(按键事件驱动,对应 App 的 `FaceToFaceTrigger.device`) |
(v0.1 的 `UP_ENABLE` 位图已删除:每条链路只有本耳一路,开关就是 `START/STOP_STREAM`。)
> ⚠️ `VAD_MODE` 默认 `0x00` 是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎, > ⚠️ `VAD_MODE` 默认 `0x00` 是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎,
> 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑 > 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑
> (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开 `0x01`。 > (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开 `0x01`。
@ -291,15 +315,18 @@ BB 90 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 | | 字段 | 说明 |
|---|---| |---|---|
| `EAR` | 低 4 位:`0x1`=左耳、`0x2`=右耳 | | `EAR` | 低 4 位:`0x1`=左耳、`0x2`=右耳(必须等于本链路耳位) |
| `FLAGS` | 高 4 位:bit4 `VAD_ACTIVE`(本包含人声)、bit5 `SEG_BEGIN`(一段发声起始)、bit6 `SEG_END`(结束)、bit7 保留 | | `FLAGS` | 高 4 位:bit4 `VAD_ACTIVE`(本包含人声)、bit5 `SEG_BEGIN`(一段发声起始)、bit6 `SEG_END`(结束)、**bit7 `OWN_VOICE`**(骨传导/VPU 判定为**佩戴者本人**在说话;固件没有该传感器时恒为 0) |
| `SEQ` | **每耳独立**自增,回绕 `0xFFFF`。App 据此算丢包,不做重传 | | `SEQ` | **每耳独立**自增,回绕 `0xFFFF`。App 据此算丢包,不做重传 |
| `PAYLOAD` | `N × 40B` G.722 码流。`FRAME_MS=40` 时 N=2,`=20` 时 N=1 | | `PAYLOAD` | `N × 40B` G.722 码流。`FRAME_MS=40` 时 N=2,`=20` 时 N=1 |
- 编码:**G.722**,16kHz / 16bit / 单声道,一帧 640B PCM(320 样本 = 20ms)→ 40B 码流。 - 编码:**G.722**,16kHz / 16bit / 单声道,一帧 640B PCM(320 样本 = 20ms)→ 40B 码流。
选它是因为现有原生插件的 `G722Codec` 编解码器两端都已落地、已验证,不引入新依赖。 选它是因为现有原生插件的 `G722Codec` 编解码器两端都已落地、已验证,不引入新依赖。
- `SEG_BEGIN` / `SEG_END` 是**耳机给的提示**,不是命令:App 可以用它抢跑一次断句, - `SEG_BEGIN` / `SEG_END` 是**耳机给的提示**,不是命令:App 可以用它抢跑一次断句,
但最终断句仍以云端 ASR 为准(`VAD_MODE=0x00` 时耳机照发不误)。 但最终断句仍以云端为准(`VAD_MODE=0x00` 时耳机照发不误)。
- **`OWN_VOICE` 是面对面串音抑制的关键**:两个人面对面坐着,A 的耳机麦一样能收到 B 的声音。
有这一位,App 可以在 `OWN_VOICE=0` 时不把这一路送去识别(或送去但标低权重)。
没有它只能靠云端两路对比,效果差一档(§14 Q6)。
- 丢包**不重传**:语音流重传只会加大延迟。App 检测到 seq 跳变时补静音帧维持时基。 - 丢包**不重传**:语音流重传只会加大延迟。App 检测到 seq 跳变时补静音帧维持时基。
--- ---
@ -312,7 +339,7 @@ AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 | | 字段 | 说明 |
|---|---| |---|---|
| `EAR` | 低 4 位:这包该播给哪只耳。档位 A 由主耳按此转发;档位 B 下与本链路耳位不符则丢弃并回 `ERR_EAR_MISMATCH` | | `EAR` | 低 4 位:这包该播给哪只耳。与本链路耳位不符则丢弃并回 `ERR_EAR_MISMATCH` |
| `FLAGS` | bit4 `PLAY_BEGIN`(一段译文开始,耳机可据此压低本地音/给提示音)、bit5 `PLAY_END`(结束,可恢复)、bit6 `FLUSH`(**打断**:丢弃本耳未播完的缓冲,立即播本包) | | `FLAGS` | bit4 `PLAY_BEGIN`(一段译文开始,耳机可据此压低本地音/给提示音)、bit5 `PLAY_END`(结束,可恢复)、bit6 `FLUSH`(**打断**:丢弃本耳未播完的缓冲,立即播本包) |
| `SEQ` | 每耳独立自增 | | `SEQ` | 每耳独立自增 |
| `PAYLOAD` | 同上行 | | `PAYLOAD` | 同上行 |
@ -320,10 +347,19 @@ AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
- **打断(`FLUSH`)是面对面必需的**:对方还在听上一句译文时新一句已经翻好, - **打断(`FLUSH`)是面对面必需的**:对方还在听上一句译文时新一句已经翻好,
没有 flush 就会越积越多、译文与对话彻底脱节。App 在每段译文首包置 `PLAY_BEGIN`, 没有 flush 就会越积越多、译文与对话彻底脱节。App 在每段译文首包置 `PLAY_BEGIN`,
需要抢播时同时置 `FLUSH`。 需要抢播时同时置 `FLUSH`。
- **不需要静音填充**。这点与 SPP 的 `AA 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧; - **积压追赶不靠 FLUSH 一种手段**。译文是云端突发产出、播放只能 1 倍速,积压必然出现。
App 侧**直接复用 `CallTranslationDownlink` 的追赶器**(裁静音 + WSOLA 变速,9-22 已在通话翻译落地并实测),
只换包头与 EAR;`FLUSH` 只留给「新一句到了、上一句还没播完」这种语义上该丢的场景。
- **不需要静音填充**。这点与经典链路 `AA 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧;
BLE 是包交换,没数据就不发包,耳机侧靠 `SEQ` 与到达时间维持时基即可。 BLE 是包交换,没数据就不发包,耳机侧靠 `SEQ` 与到达时间维持时基即可。
- **节流**:App 按 `FRAME_MS` 的实时速率发送。耳机可用 `BB 86 <interval_ms>` 请求改节奏 但**起播前先垫 4 包编码静音**再发真音频(9-23 定性:从空缓冲起播必出杂音),
(语义对齐现有 SPP 的 `0xD6/0xE9` 调速),合法值 `16 / 20 / 25 / 40`。 一段结束后再续 500ms 静音才停发。
- **节奏调整:按包,不按速率。** 耳机用 `BB 88 <ADJ int8>` 请求:`+1`=缓冲低一包、请立即补一包;
`-1`=缓冲高一包、请扣一包;`0`=缓冲已空(App 视为起播,重新垫静音)。
⚠️ v0.1 写的「`interval_ms` 16/20/25/40 对齐 `0xD6/0xE9`」是对 `D6` 的误读——
9-23 真机定性 `D6` 的 `01` 只持续 20ms 就回 `02`,语义是「补一包」不是「改速率」。新协议直接按补/扣包定义,别再重现这个坑。
- App 发包**定时器必须固定节拍、永不重排**(iOS `DispatchSourceTimer` / Android `scheduleAtFixedRate`),
编码与写入耗时不能叠进周期(9-23 第 1 条)。
- 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。 - 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。
--- ---
@ -336,12 +372,12 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `EVENT` | 含义 | PARAM | App 侧对应 | | `EVENT` | 含义 | PARAM | App 侧对应 |
|---|---|---|---| |---|---|---|---|
| `0x01` | 按键 | `<EAR><KEY_CODE><ACTION>`,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller` 落地,固件只需如实上报 | | `0x01` | 按键 | `<EAR><KEY_CODE><ACTION>`,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller.setActiveSpeaker` 落地,固件只需如实上报 |
| `0x02` | 佩戴状态 | `<EAR><STATE>`,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 | | `0x02` | 佩戴状态 | `<EAR><STATE>`,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 |
| `0x03` | 电量 | `<EAR><LEVEL>`,最高位为充电位 | 与 `BB 0A` 同口径 | | `0x03` | 电量 | `<EAR><LEVEL>`,最高位为充电位 | 与 `BB 0A` 同口径 |
| `0x04` | 链路统计 | `<EAR><UP_LOSS%><DOWN_LOSS%>` | 每 10s 一次,仅用于诊断日志 | | `0x04` | 链路统计 | `<EAR><UP_LOSS%><DOWN_LOSS%>` | 每 10s 一次,仅用于诊断日志 |
| `0x05` | 异常 | `<CODE>`,见 §12 | 缓冲溢出、编码失败等 | | `0x05` | 异常 | `<CODE>`,见 §12 | 缓冲溢出、编码失败等 |
| `0x06` | 耳机请求退出 | `<REASON>` | 用户在耳机上操作或电量过低;App 收到后走正常退出流程 | | `0x06` | 耳机请求退出 | `<REASON>`:`0x01`用户操作 / `0x02`电量过低 / `0x03`**来电** / `0x04`对耳失联 | App 收到后走正常退出流程;来电见 §11.5 |
--- ---
@ -349,34 +385,37 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。 功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。
### 11.1 三层看门狗 ### 11.1 看门狗
| 层 | 条件 | 动作 | 责任方 | | 层 | 条件 | 动作 | 责任方 |
|---|---|---|---| |---|---|---|---|
| 1 | 开始广播后 **30s** 无 BLE 连接 | 停广播、退出面对面模式、恢复常态、SPP 主动上报 `BB EC 00` | 固件 | | 1 | 开始广播后 **30s** 无 LE 连接 | 该耳退出面对面;两耳都退出后重新组对、恢复常态、经典链路上报 `BB EC 00` | 固件 |
| 2 | BLE 已连接但 **5s** 未收到 `AA 05` KEEPALIVE | 断 BLE、退出面对面模式、恢复常态 | 固件 | | 2 | **LE 连接断开**(supervision timeout 2~4s,§6.2) | 该耳立即退出面对面;等待对耳 ≤5s 后重新组对 | 固件 |
| 3 | BLE 意外断开 / App 侧异常 | 立即补发 SPP `AA 6C`;SPP 也不可用时,**下次 SPP 连上后先发一次 `AA 6C`** 清状态 | App | | 3 | App 侧异常 / 主动退出 | 每条链路发 `AA 06 EXIT`;再补发经典链路 `AA 6C`;经典链路也不可用时,**下次连上后先发一次 `AA 6C`** 清状态 | App |
> 第 2 层是本协议里最重要的一条。App 被系统杀掉、崩溃、用户强退时, > **第 2 层是本协议里最重要的一条,且刻意不靠 keepalive。** App 被系统杀掉、崩溃、用户强退时,
> 没有任何机会发退出指令;缺了它耳机会永远卡在面对面模式,用户表现为 > 没有任何机会发退出指令;但 LE 连接本身会在 supervision timeout 内必然断开,固件收到断连事件即可恢复。
> 「耳机连着但放不了音乐、也接不了电话」,且看不出原因。 > v0.1 的「5s 没收到 keepalive 就断链」删掉了:keepalive 若由 App 定时器发,iOS 锁屏后定时器一停就会被耳机踢出,
> 把一个正常场景做成了故障;而链路断开这个信号不依赖任何定时器。
> 缺了第 2 层耳机会永远卡在面对面模式,用户表现为「耳机连着但放不了音乐、也接不了电话」,且看不出原因。
> (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。) > (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。)
### 11.2 固件的恢复清单 ### 11.2 固件的恢复清单
收到 `AA 6C`、或任一看门狗触发时,**逐项恢复**: 收到 `AA 06 EXIT` / `AA 6C`、或任一看门狗触发时,**逐项恢复**:
1. 停止 BLE 广播,断开本协议的 GATT 连接; 1. 停止 LE 广播,断开本协议的 GATT 连接;
2. 恢复 TWS 主从与耳间同步(档位 B 拆过主从时); 2. **两耳重新组对,恢复 TWS 主从与耳间同步**;
3. 恢复 A2DP / HFP —— 音乐能播、来电能接; 3. 恢复 A2DP / HFP —— 音乐能播、来电能接;
4. 恢复按键功能映射到用户原设置(面对面期间若临时改过键位); 4. 恢复按键功能映射到用户原设置(面对面期间若临时改过键位);
5. 恢复 ANC / 通透模式到进入前的档位; 5. 恢复 ANC / 通透模式到进入前的档位;
6. 恢复麦克风通路与增益; 6. 恢复麦克风通路与增益;
7. SPP 回 `BB EC 00`(看门狗触发时为主动上报)。 7. 经典链路回 `BB EC 00`(看门狗触发时为主动上报);`CLASSIC_KEEP=0` 的固件在重新组对后**主动回连**手机的经典链路。
### 11.3 App 的恢复动作 ### 11.3 App 的恢复动作
- 退出 BLE 模式后不重连 SPP:SPP 链路全程未断,`BesBluetoothService` 状态不受影响; - `CLASSIC_KEEP=1` 时经典链路全程未断,`BesBluetoothService` 状态不受影响;
`=0` 时等固件回连,期间设备页显示「恢复中」,不当成掉线报错;
- 恢复完成前不允许再次进入面对面(防止半状态叠加); - 恢复完成前不允许再次进入面对面(防止半状态叠加);
- UI 上「结束」按钮必须**先返回、后台跑收尾**,不要 await 整条退出链 - UI 上「结束」按钮必须**先返回、后台跑收尾**,不要 await 整条退出链
(录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。 (录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。
@ -385,6 +424,16 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
面对面 BLE 模式与下列功能**同一时刻只能有一个**,冲突时 `AA 6B` 回 `ERR_BUSY`: 面对面 BLE 模式与下列功能**同一时刻只能有一个**,冲突时 `AA 6B` 回 `ERR_BUSY`:
通话翻译(`AA 51`)、AI 对话(`0x64`)、主麦录音(`AA 59`)、媒体录音(`AA 5E`)、OTA、旧版面对面(`AA 66`)。 通话翻译(`AA 51`)、AI 对话(`0x64`)、主麦录音(`AA 59`)、媒体录音(`AA 5E`)、OTA、旧版面对面(`AA 66`)。
App 侧同样在 `DeviceConnection.isBlockedByCall` 这一层拦,双保险。
### 11.5 单耳失联与来电
- **单耳 LE 断开**(走远、没电):固件该耳退出并等待对耳;App 侧另一耳仍连着,
**停掉该耳的上行与对耳的下行**(一方听不到了,继续翻译没有意义),提示「左/右耳已断开」,
10s 内重扫同 token 尝试回连,超时走完整退出。
- **来电**:TWS 已拆、HFP 已停,来电无法在耳机上接。固件收到系统来电 → 上报 `EVT 0x06 REASON=0x03` →
App 立即走退出流程并提示「有来电,面对面翻译已结束」;固件恢复 HFP 后由系统接管来电。
⚠️ 这一步的时序(振铃到 HFP 恢复要多久)是 §14 Q2 的实测项,决定用户会不会漏接。
--- ---
@ -395,7 +444,7 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `0x00` | `OK` | 成功 | — | | `0x00` | `OK` | 成功 | — |
| `0x01` | `ERR_UNSUPPORTED` | 固件不支持本协议或版本过低 | 提示「耳机固件需升级」,引导 OTA | | `0x01` | `ERR_UNSUPPORTED` | 固件不支持本协议或版本过低 | 提示「耳机固件需升级」,引导 OTA |
| `0x02` | `ERR_BUSY` | 正在通话/AI/录音/OTA | 提示「请先结束当前功能」 | | `0x02` | `ERR_BUSY` | 正在通话/AI/录音/OTA | 提示「请先结束当前功能」 |
| `0x03` | `ERR_EAR_UNAVAILABLE` | 单耳或双耳不可用(在盒/未佩戴) | 提示「请取出并佩戴双耳」 | | `0x03` | `ERR_EAR_UNAVAILABLE` | 任一耳不可用(在盒/未佩戴/没电) | 提示「请取出并佩戴双耳」 |
| `0x04` | `ERR_TOKEN_MISMATCH` | token 校验失败 | 静默重试一次,仍失败则回滚 | | `0x04` | `ERR_TOKEN_MISMATCH` | token 校验失败 | 静默重试一次,仍失败则回滚 |
| `0x05` | `ERR_BLE_RESOURCE` | BLE 资源不足(连接数/内存) | 提示重试 | | `0x05` | `ERR_BLE_RESOURCE` | BLE 资源不足(连接数/内存) | 提示重试 |
| `0x06` | `ERR_CODEC` | 编码不支持或初始化失败 | 记日志,回滚 | | `0x06` | `ERR_CODEC` | 编码不支持或初始化失败 | 记日志,回滚 |
@ -403,25 +452,28 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `0x08` | `ERR_EAR_MISMATCH` | 下行包耳位与链路不符 | 记日志(属 App 侧 bug) | | `0x08` | `ERR_EAR_MISMATCH` | 下行包耳位与链路不符 | 记日志(属 App 侧 bug) |
| `0x09` | `EVT_BUF_OVERFLOW` | 下行缓冲溢出 | 记日志,App 降速 | | `0x09` | `EVT_BUF_OVERFLOW` | 下行缓冲溢出 | 记日志,App 降速 |
| `0x0A` | `ERR_INTERNAL` | 固件内部错误 | 回滚并提示 | | `0x0A` | `ERR_INTERNAL` | 固件内部错误 | 回滚并提示 |
| `0x0B` | `ERR_TWS_SPLIT` | 拆主从失败 / 对耳不应答 | 回滚并提示「请把双耳放回盒中再取出重试」 |
--- ---
## 13. 带宽与延迟预算 ## 13. 带宽与延迟预算
**带宽**(G.722 @16kHz = 16 kbps/路) **带宽**(G.722 @16kHz = 16 kbps/路,**每条链路各承担一上一下**)
| 流 | 路数 | 小计 | | 每条 LE 链路 | 小计 |
|---|---|---| |---|---|
| 上行(左耳 + 右耳) | 2 | 32 kbps | | 上行 1 路 | 16 kbps |
| 下行(左耳 + 右耳) | 2 | 32 kbps | | 下行 1 路 | 16 kbps |
| 控制 + keepalive | — | < 2 kbps | | 控制 + keepalive | < 1 kbps |
| **合计** | | **≈ 66 kbps** | | **单链路合计** | **≈ 33 kbps** |
40ms 聚合时每包 86 字节,四条流合计每 40ms 4 包 ≈ 8.6 KB/s。 40ms 聚合时每包 86 字节,单链路每 40ms 2 包 ≈ 4.3 KB/s。
BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**; BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**;开 2M PHY + DLE 后更宽松。
开 2M PHY + DLE 后更宽松。档位 B 下两条链路各承担一半,压力更小。
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps,四路 1 Mbps —— BLE 上不可行。 > ⚠️ 手机侧是**两条 LE 连接并发**,再加主耳可能还挂着一条 BR/EDR,三条链路共用手机一根天线的时隙。
> 单链路余量充足不等于三链路共存余量充足,§14 Q4 要固件给**共存下**的实测数,不是单链路理论值。
>
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps —— BLE 上不可行。
> **所以下行/上行都必须是编码后码流,不能传裸 PCM。** > **所以下行/上行都必须是编码后码流,不能传裸 PCM。**
**延迟预算**(单向,从 A 开口到 B 听到) **延迟预算**(单向,从 A 开口到 B 听到)
@ -430,13 +482,17 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
|---|---|---| |---|---|---|
| 耳机采集 + 编码 | 20~40ms | 一个聚合包 | | 耳机采集 + 编码 | 20~40ms | 一个聚合包 |
| BLE 上行 | 30~60ms | 含连接间隔与重传 | | BLE 上行 | 30~60ms | 含连接间隔与重传 |
| 档位 A 的耳间转发 | +10~20ms | 仅档位 A、且仅下行侧 | | 云端翻译 | **800ms(端到端)/ 2000ms+(三段串行)** | 大头,见下 |
| 云端 ASR→翻译→TTS | **800~2500ms** | 大头。端到端模型可压到 ~800ms,三段串行则 2s+ |
| BLE 下行 | 30~60ms | | | BLE 下行 | 30~60ms | |
| 解码 + 抖动缓冲 + 播放 | 60~120ms | | | 解码 + 抖动缓冲 + 播放 | 60~120ms | |
| **合计** | **≈ 1.0~2.8s** | | | **合计** | **≈ 1.0 / 2.3s** | |
> 蓝牙链路只占其中 100~250ms。**优化重心在云端那一段**,不要在协议上过度抠 10ms。 > 蓝牙链路只占其中 100~250ms。**优化重心在云端那一段**,不要在协议上过度抠 10ms。
>
> ⚠️ **云端走端到端是本功能的前置工作,不是可选优化。** 面对面当前是 ASR → HTTP 机器翻译 → TTS 三段串行
> (CLAUDE.md「面对面 / 同声翻译:延迟构成」),2s+ 的等待配上按住说话,体验不成立。
> 阿里端到端 AST 的 `pushExternalAudio(leg:)` 已现成,面对面的两只耳机恰好与 AST 的 leg A / leg B 同构:
> 左耳上行进 leg A、译文回右耳;右耳上行进 leg B、译文回左耳。通话翻译那套原生桥(`BesCallPcmBridge`)可直接复用。
--- ---
@ -446,20 +502,22 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 问题 | 影响 | | # | 问题 | 影响 |
|---|---|---| |---|---|---|
| Q1 | 能否做**档位 B(双链路)**?拆开 TWS 主从后,两只耳机还能各自独立跑 GATT 并保持同步吗? | 决定最终形态;不能则先做档位 A | | Q1 | **拆 TWS 主从后**,两只耳机能否各自独立跑 GATT peripheral?拆开后**主耳到手机的经典链路能否保持**(`CLASSIC_KEEP`)?iOS 上那条是 BR/EDR GATT,与两条 LE 共存有没有限制? | 决定退出通道(§3 ⑬⑭)与 §11.3 |
| Q2 | 面对面模式下 **A2DP / HFP 是否必须中断**?若中断,来电时如何处理(自动退出面对面?) | 决定 §11 恢复清单与用户提示文案 | | Q2 | 面对面模式下 A2DP / HFP 必然中断。**来电时**从振铃到 `EVT 0x06` 上报、再到 HFP 恢复可接听,各需多久? | 决定 §11.5 文案与是否可接受漏接 |
| Q3 | §6.1 的 **GATT UUID 由谁分配**?固件方是否已有预留的 128-bit 基址? | 阻塞两端编码 | | Q3 | §6.1 的 **GATT UUID 由谁分配**?固件方是否已有预留的 128-bit 基址? | 阻塞两端编码 |
| Q4 | 支持 **MTU 247 / 2M PHY / DLE** 吗?最大支持的 MTU 是多少? | 决定聚合帧长(40ms vs 20ms) | | Q4 | 支持 **MTU 247 / 2M PHY / DLE** 吗?两条 LE + 一条 BR/EDR **共存下**的实测吞吐与丢包? | 决定聚合帧长(40ms vs 20ms) |
| Q5 | **G.722 能否直接用在 BLE 通道**上(现有实现在 SPP 上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 | | Q5 | **G.722 能否直接用在 BLE 通道**上(现有实现在经典链路上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 |
| Q6 | 是否有**入耳检测 / 骨传导 / VPU** 可用来判定「是佩戴者本人在说话」?能否随上行包给出该标志? | 面对面串音抑制的关键;没有的话只能靠云端,效果差一档 | | Q6 | 是否有**入耳检测 / 骨传导 / VPU** 可判定「是佩戴者本人在说话」?能否随上行包置 `OWN_VOICE`(§8)? | 面对面串音抑制的关键;没有的话只能靠云端,效果差一档 |
| Q7 | 面对面模式下**哪些按键可用**、键位码如何编排?长按/抬起事件能否上报? | 决定「按住说话」能否做 | | Q7 | 面对面模式下**哪些按键可用**、键位码如何编排?按下/抬起事件能否上报? | 决定「按住说话」能否做 |
| Q8 | 广播里的 **TOKEN 回显**能实现吗?广播内容能否在运行时动态改? | 不能则无法防止连错耳机,需另想办法 | | Q8 | 广播里的 **TOKEN 回显**能实现吗?广播内容能否在运行时动态改? | 不能则无法防止连错耳机,需另想办法 |
| Q9 | **单耳在盒 / 只戴一只**时的降级行为?是拒绝进入,还是以单耳模式进入? | 决定 `EAR_MASK` 语义与 App 提示 | | Q9 | **拆主从 + 两耳各起广播**从收到 `AA 6B` 到广播出现要多久?重新组对要多久? | 决定 §5.2 扫描超时与 T12 连续进出的可行性 |
| Q10 | §11.1 的三层**看门狗超时值**(30s / 5s)是否合适?固件侧能否实现第 1、2 层? | 直接决定「App 崩了耳机会不会卡死」 | | Q10 | §11.1 看门狗 1(30s)与 supervision timeout(2~4s)能否实现?断连后重新组对的等待(5s)是否合适? | 直接决定「App 崩了耳机会不会卡死」 |
| Q11 | 与 **OTA / AI 对话 / 通话**的互斥由固件拦(回 `ERR_BUSY`)还是 App 自律? | 建议固件拦,App 也拦,双保险 | | Q11 | 与 **OTA / AI 对话 / 通话**的互斥由固件拦(回 `ERR_BUSY`)还是 App 自律? | 建议固件拦,App 也拦,双保险 |
| Q12 | 档位 B 下两只耳机的**上行时间戳是否需要对齐**?耳间有共同时钟吗? | 影响能否做跨耳的回声/串音抑制 | | Q12 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 |
| Q13 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 | | Q13 | 耳机侧**下行抖动缓冲**多大?溢出策略是丢旧还是丢新?`BB 88` 补/扣包的触发阈值是多少? | 影响打断(FLUSH)与追赶的实际效果 |
| Q14 | 耳机侧**下行抖动缓冲**多大?溢出策略是丢旧还是丢新? | 影响打断(FLUSH)的实际效果 | | Q14 | 单耳失联后另一耳如何得知(§11.5 `REASON=0x04`)?拆主从后两耳之间还有没有任何私有链路? | 决定单耳失联能否由固件自己发现 |
(v0.1 的 Q1「能否做双链路」已由产品决定为必做;Q12「上行时间戳是否对齐」删除——两人各听各的,不需要对齐。)
--- ---
@ -467,20 +525,23 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 场景 | 期望 | | # | 场景 | 期望 |
|---|---|---| |---|---|---|
| T01 | 正常进入 → 双耳广播 → 连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 | | T01 | 正常进入 → 拆主从 → 双耳广播 → 分别连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 |
| T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 | | T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 |
| T03 | 双方同时说话 | 两路各自独立上行,互不排队,两路译文分别到达对侧 | | T03 | 双方同时说话 | 两路各自独立上行,互不排队,两路译文分别到达对侧 |
| T04 | 打断:A 的第二句在第一句译文播完前到达 | 置 `FLUSH` 后立即切到新句,不排队堆积 | | T04 | 打断:A 的第二句在第一句译文播完前到达 | 置 `FLUSH` 后立即切到新句,不排队堆积 |
| T05 | 扫描期旁边有另一副同型号耳机(也在面对面模式) | **绝不连错**:token 不匹配的广播被忽略 | | T05 | 扫描期旁边有另一副同型号耳机(也在面对面模式) | **绝不连错**:token 不匹配的广播被忽略 |
| T06 | 正常退出 | 音乐可播、来电可接、按键功能恢复、ANC 恢复 | | T06 | 正常退出 | 两耳重新组对,音乐可播、来电可接、按键功能恢复、ANC 恢复 |
| T07 | **App 进程被杀** | ≤ 5s 后耳机自行退出并恢复常态(看门狗 2) | | T07 | **App 进程被杀** | supervision timeout 内两条 LE 断开,≤ 10s 耳机重新组对并恢复常态(看门狗 2) |
| T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出(看门狗 1) | | T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出并组对(看门狗 1) |
| T09 | 面对面期间来电 | 按 Q2 的结论执行,且结束后状态一致 | | T09 | 面对面期间来电 | 按 Q2 的结论执行,用户能接到电话,结束后状态一致 |
| T10 | 面对面期间摘下一只耳 / 放回盒 | 上报 `EVT 0x02`,App 暂停该路并提示,戴回后恢复 | | T10 | 面对面期间摘下一只耳 / 放回盒 | 上报 `EVT 0x02`,App 暂停该路并提示,戴回后恢复 |
| T11 | 走出距离边缘导致丢包 | 无崩溃、无杂音,seq 补洞,恢复后自愈 | | T11 | 一只耳走远导致该链路断开 | 另一耳停流并提示,10s 内回来能续上;超时后完整退出、两耳组对 |
| T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当 | | T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当,每次都成功组对 |
| T13 | 重复发 `AA 6C` 三次 | 三次都回 `BB EC 00`(幂等) | | T13 | 重复发 `AA 6C` 三次 | 三次都回 `BB EC 00`(幂等) |
| T14 | 面对面期间尝试进 AI / 通话翻译 | 回 `ERR_BUSY`,两边状态都不被破坏 | | T14 | 面对面期间尝试进 AI / 通话翻译 | 回 `ERR_BUSY`,两边状态都不被破坏 |
| T15 | 单耳在盒时发 `AA 6B` | 回 `ERR_EAR_UNAVAILABLE`,不拆主从 |
| T16 | 两人面对面坐着、只有 A 说话 | B 那只耳机的上行 `OWN_VOICE=0`(Q6 成立时),B 侧不产生识别结果 |
| T17 | 一句 20 秒不换气的长句 | 下行积压由追赶器(裁静音 + 变速)消化,无杂音、无整段丢弃 |
--- ---
@ -489,3 +550,4 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| 版本 | 日期 | 变更 | | 版本 | 日期 | 变更 |
|---|---|---| |---|---|---|
| v0.1 | 2026-09-05 | 首版草案,待固件确认 §14 | | v0.1 | 2026-09-05 | 首版草案,待固件确认 §14 |
| v0.2 | 2026-09-23 | **形态定为两耳独立 BLE**:删除档位 A 与 `TOPO`/`TOPO_REQ`/`UP_ENABLE`;`AA 6B` 长度 07→06,`BB EB` 新增 `CLASSIC_KEEP`;LE 侧新增 `AA 06 EXIT`;看门狗 2 改为「链路断开即退出」,keepalive 降为诊断(1s→5s);`BB 86` 调速改为 `BB 88` 按包补/扣(修正对 `D6` 的误读);上行 FLAGS bit7 定义为 `OWN_VOICE`;补 §1.1 iOS 是 BR/EDR GATT、§1.2 App 载体改为原生直连(`face_to_face_ble_service.dart` 已删)、§5.2 前台扫描、§6.3 写入规则、§9 复用追赶器、§11.5 单耳失联与来电、§13 端到端为前置;`EVT 0x06` 增加 REASON 表;新增 `ERR_TWS_SPLIT`、T15~T17 |

22
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 60; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -338,7 +338,7 @@
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
); );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -389,14 +389,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -441,14 +437,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -658,7 +650,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar; PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -852,7 +844,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar; PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -886,7 +878,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar; PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -996,7 +988,7 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
}; };
@ -1005,7 +997,7 @@
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */; package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage; productName = FlutterGeneratedPluginSwiftPackage;
}; };
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */

2
apps/client/ios/Runner/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string> <string>com.yimai.eaimar</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>voitrans</string> <string>voitrans</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>

2
apps/client/pubspec.yaml

@ -1,7 +1,7 @@
name: eaimar name: eaimar
description: "Eaimar - AI Voice Assistant." description: "Eaimar - AI Voice Assistant."
publish_to: "none" publish_to: "none"
version: 1.0.2+19 version: 1.0.2+21
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"

Loading…
Cancel
Save