Browse Source
Echo-translation 与 Echo-one 共用恒玄链路;繁体、乌克兰语、克罗地亚语改走微软翻译。构建号 1.0.2+27。 Co-authored-by: Cursor <cursoragent@cursor.com>along-test
106 changed files with 5013 additions and 388 deletions
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 25 KiB |
@ -0,0 +1,313 @@ |
|||
# Echo-one 面对面翻译 · 双耳 BLE 协议(App 当前实现) |
|||
|
|||
日期:2026-10-10 |
|||
对象:固件 |
|||
对应 App:已按本文组包、解析。请对照固件实现逐条核对。 |
|||
更早的讨论稿见 [face_to_face_ble_protocol.md](face_to_face_ble_protocol.md)(草案 v0.2)。**以本文为准**,不一致的地方按本文改固件或回我。 |
|||
|
|||
--- |
|||
|
|||
## 1. 形态 |
|||
|
|||
两个人各戴一只耳机。TWS 拆开后,左耳、右耳各自一条 BLE 连到同一部手机。手机不收音、不放音,只做字幕和转发。 |
|||
|
|||
| 谁在说 | 麦从哪来 | 云端 | 译文播到哪 | |
|||
|---|---|---|---| |
|||
| 戴左耳的人(源语言) | 左耳 `BB 90`,`EAR=1` | A 路 | 右耳 `AA 91`,`EAR=2` | |
|||
| 戴右耳的人(目标语言) | 右耳 `BB 90`,`EAR=2` | B 路 | 左耳 `AA 91`,`EAR=1` | |
|||
|
|||
两条 BLE 互相独立,可以同时上行。经典蓝牙只用来进出这个模式(`AA 6B` / `AA 6C`),不传面对面的音频。 |
|||
|
|||
旧的面对面 `AA 66` / `AA 67` 不动,和新模式互斥。通话翻译、AI、录音、OTA 也不并行。正在做这些事时,`AA 6B` 应回 `ERR_BUSY (0x02)`。 |
|||
|
|||
--- |
|||
|
|||
## 2. 两条 BLE 通道 |
|||
|
|||
左右耳的 GATT **完全一样**。耳位不靠 UUID 区分,靠广播里的 `EAR`,以及音频帧低 4 位。 |
|||
|
|||
| 特征 | UUID | 属性 | 方向 | |
|||
|---|---|---|---| |
|||
| Service | `AEAF5F32-0001-4C4B-A100-000000000001` | — | 扫描按这个 UUID 过滤 | |
|||
| `CTRL_TX` | `AEAF5F32-0002-4C4B-A100-000000000001` | Write Without Response | App → 耳机,握手 / 配置 / 保活 / 退出 | |
|||
| `CTRL_RX` | `AEAF5F32-0003-4C4B-A100-000000000001` | Notify | 耳机 → App,握手应答 / 事件 | |
|||
| `AUDIO_UP` | `AEAF5F32-0004-4C4B-A100-000000000001` | Notify | 耳机 → App,本耳麦,`BB 90` | |
|||
| `AUDIO_DOWN` | `AEAF5F32-0005-4C4B-A100-000000000001` | Write Without Response | App → 耳机,译文,`AA 91` | |
|||
|
|||
控制和音频必须分开。停流、打断如果排在音频写后面,点结束会没反应。 |
|||
|
|||
链路要求(每条 LE 各自满足): |
|||
|
|||
| 项 | 要求 | |
|||
|---|---| |
|||
| 写类型 | `CTRL_TX`、`AUDIO_DOWN` 都认 **Write Without Response** | |
|||
| ATT MTU | App 请求 247。40ms 包大约 86 字节,MTU 至少要装得下一包 | |
|||
| PHY | 优先 2M,不行再 1M | |
|||
| 连接间隔 | 15~30ms。大于 40ms 译文会断 | |
|||
| Slave latency | 0 | |
|||
| Supervision timeout | 2~4s。App 被杀掉时来不及发退出,靠这条超时让固件自己恢复 | |
|||
|
|||
BLE 帧格式(和经典蓝牙同形,操作码空间独立): |
|||
|
|||
```text |
|||
AA/BB | OP | LEN | payload |
|||
``` |
|||
|
|||
`LEN` 是 payload 的字节数,不含这 3 字节头。 |
|||
|
|||
--- |
|||
|
|||
## 3. 启动时序 |
|||
|
|||
耳机平时不要广播这个服务。只有收到经典链路上的 `AA 6B` 才拆 TWS、开始广播。 |
|||
|
|||
```text |
|||
App 经典蓝牙 左耳 BLE 右耳 BLE |
|||
│ AA 6B │ │ │ |
|||
│──────────────────────────►│ 拆 TWS,两耳广播 │ │ |
|||
│ BB EB │ │ │ |
|||
│◄──────────────────────────│ │ │ |
|||
│ 扫描,TOKEN 对上,集齐左右耳后分别连接 │ │ |
|||
│ 订阅 CTRL_RX、AUDIO_UP │ │ │ |
|||
│───────────────────────────────────────────────►│─────────────────►│ |
|||
│ HELLO → CFG → START(每只耳朵各一遍) │ │ |
|||
│ BB 90 左耳麦 │ │ │ |
|||
│◄═══════════════════════════════════════════════│ │ |
|||
│ AA 91 译文给右耳 │ │ │ |
|||
│══════════════════════════════════════════════════════════════════►│ |
|||
│ BB 90 右耳麦 / AA 91 译文给左耳,方向相反 │ │ |
|||
``` |
|||
|
|||
两只耳朵的握手互不等待。**两只都回了 `BB 83` 才开始推音频。** 12 秒内没握完,App 整场退出。 |
|||
|
|||
--- |
|||
|
|||
## 4. 经典蓝牙:进入 / 退出 |
|||
|
|||
走现有经典链路(Android SPP,iOS GATT over BR/EDR)。不是 BLE。 |
|||
|
|||
### 4.1 进入 `AA 6B` |
|||
|
|||
App 每次进入重新生成 4 字节随机 TOKEN。 |
|||
|
|||
```text |
|||
AA 6B 06 02 01 <TOKEN 4 字节> |
|||
``` |
|||
|
|||
| 字节 | 值 | 含义 | |
|||
|---|---|---| |
|||
| PROTO_VER | `0x02` | 本协议版本 | |
|||
| CAP | `0x01` | 只要 G.722 | |
|||
|
|||
App 等 `BB EB` 的上限是 **3 秒**。超时就放弃,并补发 `AA 6C`。 |
|||
|
|||
### 4.2 应答 `BB EB` |
|||
|
|||
整帧 10 字节: |
|||
|
|||
```text |
|||
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP> |
|||
``` |
|||
|
|||
| 字段 | App 怎么用 | |
|||
|---|---| |
|||
| RESULT | `0x00` 才继续扫 BLE。非 0 按第 8 节提示用户,并退出 | |
|||
| EAR_MASK | bit0 = 左耳可用,bit1 = 右耳可用。**必须是 `0x03`**。缺一只不进入,请直接回 `0x03`,不要拆了一半 | |
|||
| CODEC | `0x01` = G.722 | |
|||
| FRAME_MS | `20` 或 `40`。决定一包里几帧。其它值 App 按 40 处理 | |
|||
| FW_MINOR | 固件小版本,App 记下来,暂不分支 | |
|||
| CLASSIC_KEEP | `0x01` = 拆 TWS 后经典链路还在;`0x00` = 经典链路会断 | |
|||
|
|||
`CLASSIC_KEEP=0` 时,App **不当成用户把耳机断开**。退出命令改走每只耳朵的 `AA 06`。下次经典蓝牙再连上,App 会先补发一次 `AA 6C`。 |
|||
|
|||
### 4.3 退出 `AA 6C` |
|||
|
|||
```text |
|||
AA 6C → BB EC 01 <RESULT> |
|||
``` |
|||
|
|||
`AA 6C` **没有长度字节**。 |
|||
|
|||
必须幂等:当前不在面对面模式,也要回 `BB EC 01 00`,不要报错。App 在异常恢复时会无条件补发。 |
|||
|
|||
`RESULT=0x00` 表示已重新组对,A2DP / HFP 恢复。 |
|||
|
|||
--- |
|||
|
|||
## 5. 广播与扫描 |
|||
|
|||
收到 `AA 6B` 且 `RESULT=0`、双耳都可用之后,左右耳各自广播。 |
|||
|
|||
厂商数据(AD Type `0xFF`)。Company ID 2 字节之后是下面 9 字节: |
|||
|
|||
```text |
|||
F2 F2 | VER | EAR | TOKEN 4 字节 | BATT |
|||
``` |
|||
|
|||
| 字段 | 要求 | |
|||
|---|---| |
|||
| MAGIC | 固定 `F2 F2` | |
|||
| VER | `0x02`,和 `AA 6B` 的 PROTO_VER 一致 | |
|||
| EAR | `0x01` 左耳,`0x02` 右耳。两只耳机的名字可以相同 | |
|||
| TOKEN | **原样回显这次 `AA 6B` 的 4 字节**。对不上 App 直接忽略 | |
|||
| BATT | 低 7 位电量 0~100,最高位充电标志 | |
|||
|
|||
App 扫描规则: |
|||
|
|||
1. 只扫上面的 Service UUID。 |
|||
2. TOKEN 必须和这次 `AA 6B` 逐字节相同。旁边另一副同型号耳机不能连上。 |
|||
3. **必须同时看到 `EAR=1` 和 `EAR=2` 才连接。** 15 秒没集齐就发 `AA 6C` 回滚。 |
|||
4. iOS 拿不到 BLE MAC,身份只认 TOKEN + EAR。 |
|||
5. 扫描在 App 前台完成。建议建链期广播间隔 30~50ms。 |
|||
6. **30 秒内没被连上,该耳自己退出面对面**;两耳都退出后重新组对,并在经典链路上报 `BB EC 01 00`。 |
|||
|
|||
连上之后该耳停止广播。 |
|||
|
|||
--- |
|||
|
|||
## 6. 每只耳朵的握手 |
|||
|
|||
GATT 连上后 App 会:请求 MTU 247、连接优先级拉高、优先 2M PHY。 |
|||
通知顺序:先 `CTRL_RX`,再 `AUDIO_UP`。两个通知都开了才发 HELLO。 |
|||
|
|||
左右耳各走一遍,内容相同,TOKEN 相同。 |
|||
|
|||
| 步骤 | App 发出 | 耳机应答 | 说明 | |
|||
|---|---|---|---| |
|||
| 1 | `AA 01 06 02 <TOKEN 4B> 01` | `BB 81 …` | HELLO。最后一字节 `01` 表示角色是 App。TOKEN 再对一次 | |
|||
| 2 | `AA 02 03 00 50 00` | `BB 82 …` | 配置。见下表 | |
|||
| 3 | `AA 03 00` | `BB 83 …` | 开流。应答成功后开始推 `BB 90` | |
|||
| 4 | `AA 05 01 <SEQ>` | 可不回 | 两只都 `BB 83` 之后,每 5 秒一次。只做诊断,**不要因为没收到保活就断链** | |
|||
|
|||
`AA 02` 的 3 字节 payload: |
|||
|
|||
| 字节 | 当前固定值 | 含义 | |
|||
|---|---|---| |
|||
| VAD_MODE | `0x00` | 常发。静音也发,不要在耳机上切句 | |
|||
| DOWN_GAIN | `0x50`(80) | 下行播放增益 0~100 | |
|||
| TRIGGER | `0x00` | 免按键,连续拾音 | |
|||
|
|||
HELLO 应答 `BB 81`:App **只看 RESULT 是不是 0**。RESULT 非 0,这只耳朵判失败,整场退出。 |
|||
`FRAME_MS` 以 `BB EB` 里那个字节为准,不从 `BB 81` 再改一回。 |
|||
|
|||
`BB 82` / `BB 83` 的 RESULT 非 0,同样整场退出。 |
|||
|
|||
开流之后如果要暂停而不断开,用 `AA 04 00`(STOP_STREAM)。当前正常结束不发这条,直接走第 7 节退出。 |
|||
|
|||
--- |
|||
|
|||
## 7. 退出 |
|||
|
|||
顺序: |
|||
|
|||
1. 每只还连着的耳朵,`CTRL_TX` 发 `AA 06 00`。耳机停流、断开这条 BLE、准备重新组对。 |
|||
2. 经典链路还在,再发 `AA 6C`,等 `BB EC 01 00`。 |
|||
3. 经典链路已经断了(`CLASSIC_KEEP=0`):这次只发 `AA 06`。App 记下标记,**下次经典蓝牙一连上就补发 `AA 6C`**。 |
|||
|
|||
固件收到 `AA 06`、`AA 6C`,或者下面任一条件,都要自己恢复,不能停在面对面模式: |
|||
|
|||
| 条件 | 谁负责 | |
|||
|---|---| |
|||
| 广播 30 秒没人连 | 固件 | |
|||
| LE 断连(supervision timeout 2~4s,含 App 被杀) | 固件。这是 App 被杀掉时的唯一恢复手段 | |
|||
| App 主动结束 | App 发 `AA 06`,再视情况发 `AA 6C` | |
|||
|
|||
恢复要做完这些:停广播、断这条 GATT、两耳重新组对、A2DP / HFP 能用、按键和降噪回到进入前的状态。`CLASSIC_KEEP=0` 的固件,组对完成后要主动把经典链路连回手机。 |
|||
|
|||
单耳 BLE 断开:App 结束整场,不再单耳继续翻。 |
|||
来电:耳机在 `CTRL_RX` 上报第 9 节的退出事件,原因 `0x03`。App 结束会话并提示用户。固件恢复 HFP,把电话交给系统。 |
|||
|
|||
--- |
|||
|
|||
## 8. 音频 |
|||
|
|||
编码固定 **G.722**。16 kHz、16 bit、单声道。20ms = 640 字节 PCM = 40 字节码流。 |
|||
|
|||
`BB EB` 的 `FRAME_MS=40`:一包 2 帧,payload 80 字节。 |
|||
`FRAME_MS=20`:一包 1 帧,payload 40 字节。 |
|||
|
|||
### 8.1 上行 `AUDIO_UP`(耳机 → App) |
|||
|
|||
```text |
|||
BB 90 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…> |
|||
``` |
|||
|
|||
### 8.2 下行 `AUDIO_DOWN`(App → 耳机) |
|||
|
|||
```text |
|||
AA 91 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…> |
|||
``` |
|||
|
|||
`LEN` = 1(耳位和标志)+ 2(序号)+ G.722 字节数。 |
|||
40ms 包:`LEN = 0x53`(83),整帧 86 字节。 |
|||
20ms 包:`LEN = 0x2B`(43),整帧 46 字节。 |
|||
|
|||
低 4 位是耳位,**必须等于这条链路自己的耳朵**。对不上就丢,并可回 `ERR_EAR_MISMATCH (0x08)`。 |
|||
下行的耳位是「播给谁」:左耳麦的译文写到右耳那条链路,帧里 `EAR=2`。 |
|||
|
|||
高 4 位: |
|||
|
|||
| 位 | 掩码 | 上行(耳机 → App) | 下行(App → 耳机) | |
|||
|---|---|---|---| |
|||
| bit4 | `0x10` | 本包含人声 | 一句译文开始 | |
|||
| bit5 | `0x20` | 一段开始 | 播放结束 | |
|||
| bit6 | `0x40` | 一段结束 | 打断:丢掉本耳还没播完的缓冲,从本包播 | |
|||
| bit7 | `0x80` | 佩戴者本人在说话 | 不用 | |
|||
|
|||
`SEQ` 每耳独立,从 0 递增,到 `0xFFFF` 回绕。丢包不重传。 |
|||
|
|||
`OWN_VOICE`(bit7):没有骨传导 / VPU 时这一位请一直发 0。 |
|||
App 只有在这只耳朵**曾经出现过 bit7=1** 之后,才丢掉 bit7=0 的包。一直是 0 的话,整路都会送去识别,不会被静音。 |
|||
|
|||
下行节奏:没音频时 App 会先垫几包编码静音再送真声音,一句结束后再补一段静音。耳机用 `BB 88` 告诉 App 缓冲松紧(写在 `CTRL_RX`): |
|||
|
|||
```text |
|||
BB 88 <LEN> <ADJ> |
|||
``` |
|||
|
|||
| ADJ | 含义 | |
|||
|---|---| |
|||
| `+1` (`0x01`) | 缓冲少一包,马上再补一包 | |
|||
| `-1` (`0xFF`) | 缓冲多一包,下一拍停发一包 | |
|||
| `0` | 缓冲已经空,App 重新垫静音再起播 | |
|||
|
|||
这是补包 / 扣包,不是把速率改成 1.25 倍。 |
|||
|
|||
--- |
|||
|
|||
## 9. 事件 |
|||
|
|||
写在 `CTRL_RX`: |
|||
|
|||
```text |
|||
BB 87 <LEN> <EVENT> <PARAM…> |
|||
``` |
|||
|
|||
| EVENT | 含义 | PARAM | App 行为 | |
|||
|---|---|---|---| |
|||
| `0x01` | 按键 | `<EAR> <KEY> <ACTION>` | ACTION `0x01` 按下、`0x02` 抬起。当前是连续拾音,按键只更新界面上的左右标记 | |
|||
| `0x02` | 佩戴 | `<EAR> <STATE>` | `0x00` 摘下,`0x01` 戴上,`0x02` 入盒。App 提示确认双耳都戴好 | |
|||
| `0x03` | 电量 | `<EAR> <LEVEL>` | 低 7 位电量,最高位充电。App 暂不展示 | |
|||
| `0x06` | 请求退出 | `<REASON>` | `0x03` 来电:App 结束面对面。`0x01` 用户操作,`0x02` 电量过低,`0x04` 对耳失联 | |
|||
|
|||
--- |
|||
|
|||
## 10. 错误码 |
|||
|
|||
`BB EB` 的 RESULT,以及握手应答里的 RESULT,用同一张表。 |
|||
|
|||
| 值 | 名称 | App 提示 | |
|||
|---|---|---| |
|||
| `0x00` | OK | 继续 | |
|||
| `0x01` | 固件不支持 / 版本过低 | 耳机固件需升级 | |
|||
| `0x02` | 正在通话、AI、录音或 OTA | 请先结束当前功能 | |
|||
| `0x03` | 有一只耳不可用 | 请确认双耳都已取出并佩戴 | |
|||
| `0x04` | TOKEN 不一致 | 耳机未就绪 | |
|||
| `0x05` | BLE 资源不足 | 耳机未就绪 | |
|||
| `0x06` | 编码失败 | 耳机未就绪 | |
|||
| `0x07` | 超时 | 耳机未就绪 | |
|||
| `0x08` | 下行耳位和这条链路不符 | 记日志 | |
|||
| `0x0A` | 固件内部错误 | 耳机未就绪 | |
|||
| `0x0B` | 拆 TWS 失败 | 请把双耳放回盒中再取出重试 | |
|||
|
|||
任一阶段失败,App 都会发 `AA 06`(连上了的耳朵)和 `AA 6C`(经典链路还在时),把耳机退回普通状态。 |
|||
@ -0,0 +1,147 @@ |
|||
import 'dart:math'; |
|||
|
|||
import 'package:dio/dio.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../models/appconfig.dart'; |
|||
|
|||
/// 微软文本翻译。同声 / 面对面 / 音视频里扩展语种走这条,不经服务端阿里。 |
|||
/// |
|||
/// 密钥与通话翻译同一套:`mt_azure` 的 subscription_key / region。 |
|||
/// 请求形状与原生 `MicrosoftTranslationServiceImpl` 一致。 |
|||
class MicrosoftTextTranslation { |
|||
static const defaultEndpoint = |
|||
'https://api.cognitive.microsofttranslator.com'; |
|||
|
|||
final Dio _dio = Dio(BaseOptions( |
|||
connectTimeout: const Duration(seconds: 10), |
|||
sendTimeout: const Duration(seconds: 10), |
|||
receiveTimeout: const Duration(seconds: 10), |
|||
)); |
|||
|
|||
Future<String?> translate({ |
|||
required String text, |
|||
required String from, |
|||
required String to, |
|||
CancelToken? cancelToken, |
|||
}) async { |
|||
final src = text.trim(); |
|||
if (src.isEmpty) return null; |
|||
final key = |
|||
AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY'); |
|||
if (key == null || key.isEmpty) { |
|||
Logger.w('MsTrans', '微软翻译密钥未下发,跳过 $from→$to'); |
|||
return null; |
|||
} |
|||
final region = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION'); |
|||
final endpoint = |
|||
AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ?? |
|||
defaultEndpoint; |
|||
final headers = <String, String>{ |
|||
'Ocp-Apim-Subscription-Key': key, |
|||
'Content-Type': 'application/json', |
|||
'X-ClientTraceId': _traceId(), |
|||
}; |
|||
if (region != null && region.isNotEmpty) { |
|||
headers['Ocp-Apim-Subscription-Region'] = region; |
|||
} |
|||
try { |
|||
final query = <String, String>{ |
|||
'api-version': '3.0', |
|||
'to': to, |
|||
}; |
|||
if (from.isNotEmpty) query['from'] = from; |
|||
final response = await _dio.post<dynamic>( |
|||
'${endpoint.replaceAll(RegExp(r'/+$'), '')}/translate', |
|||
queryParameters: query, |
|||
data: [ |
|||
{'text': src}, |
|||
], |
|||
options: Options(headers: headers), |
|||
cancelToken: cancelToken, |
|||
); |
|||
final out = microsoftTranslationText(response.data); |
|||
if (out == null) { |
|||
Logger.w('MsTrans', '微软翻译返回空: $from→$to'); |
|||
} |
|||
return out; |
|||
} on DioException catch (e) { |
|||
if (e.type == DioExceptionType.cancel) return null; |
|||
Logger.w('MsTrans', '微软翻译失败($from→$to): $e'); |
|||
return null; |
|||
} catch (e) { |
|||
Logger.w('MsTrans', '微软翻译失败($from→$to): $e'); |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 阿里机器翻译盖不住的语种:繁体会被收成简体,乌克兰语和克罗地亚语直接被拒。 |
|||
bool microsoftMtRequired(String from, String to) { |
|||
bool hit(String raw) { |
|||
switch (raw.trim().toLowerCase()) { |
|||
case 'zh-hant': |
|||
case 'zh-hant-hk': |
|||
case 'zh-tw': |
|||
case 'zh-hk': |
|||
case 'uk': |
|||
case 'uk-ua': |
|||
case 'hr': |
|||
case 'hr-hr': |
|||
return true; |
|||
default: |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return hit(from) || hit(to); |
|||
} |
|||
|
|||
/// 收成微软翻译认的码。空串留给自动检测,请求里不带 from。 |
|||
String microsoftMtCode(String raw) { |
|||
final code = raw.trim(); |
|||
if (code.isEmpty) return ''; |
|||
switch (code.toLowerCase()) { |
|||
case 'zh-hant': |
|||
case 'zh-hant-hk': |
|||
case 'zh-tw': |
|||
case 'zh-hk': |
|||
return 'zh-Hant'; |
|||
case 'zh': |
|||
case 'zh-cn': |
|||
case 'zh-hans': |
|||
return 'zh-Hans'; |
|||
case 'uk': |
|||
case 'uk-ua': |
|||
return 'uk'; |
|||
case 'hr': |
|||
case 'hr-hr': |
|||
return 'hr'; |
|||
default: |
|||
final dash = code.indexOf('-'); |
|||
if (dash > 0) return code.substring(0, dash).toLowerCase(); |
|||
return code.toLowerCase(); |
|||
} |
|||
} |
|||
|
|||
/// 解析微软翻译 3.0 的响应。失败或空译文返回 null。 |
|||
String? microsoftTranslationText(dynamic data) { |
|||
if (data is! List || data.isEmpty) return null; |
|||
final first = data.first; |
|||
if (first is! Map) return null; |
|||
final translations = first['translations']; |
|||
if (translations is! List || translations.isEmpty) return null; |
|||
final item = translations.first; |
|||
if (item is! Map) return null; |
|||
final text = item['text']; |
|||
if (text is! String) return null; |
|||
final out = text.trim(); |
|||
return out.isEmpty ? null : out; |
|||
} |
|||
|
|||
String _traceId() { |
|||
final rand = Random(); |
|||
String hex(int n) => |
|||
List.generate(n, (_) => rand.nextInt(16).toRadixString(16)).join(); |
|||
return '${hex(8)}-${hex(4)}-4${hex(3)}-${hex(4)}-${hex(12)}'; |
|||
} |
|||
@ -0,0 +1,267 @@ |
|||
/// Echo-one 面对面 BLE 协议的纯函数编解码。 |
|||
/// |
|||
/// 字节布局以 `docs/face_to_face_ble_protocol.md` v0.2 为准, |
|||
/// 由 `test/devices/bes_f2f_protocol_test.dart` 钉死。原生组包必须与这里一致。 |
|||
library; |
|||
|
|||
class BesF2f { |
|||
BesF2f._(); |
|||
|
|||
static const int protoVer = 0x02; |
|||
static const int capG722 = 0x01; |
|||
static const int codecG722 = 0x01; |
|||
|
|||
static const int earLeft = 0x01; |
|||
static const int earRight = 0x02; |
|||
|
|||
static const int magic0 = 0xF2; |
|||
static const int magic1 = 0xF2; |
|||
|
|||
/// 控制与音频分开的四个特征。UUID 按协议占位,固件已按此实现。 |
|||
static const String serviceUuid = 'AEAF5F32-0001-4C4B-A100-000000000001'; |
|||
static const String ctrlTxUuid = 'AEAF5F32-0002-4C4B-A100-000000000001'; |
|||
static const String ctrlRxUuid = 'AEAF5F32-0003-4C4B-A100-000000000001'; |
|||
static const String audioUpUuid = 'AEAF5F32-0004-4C4B-A100-000000000001'; |
|||
static const String audioDownUuid = 'AEAF5F32-0005-4C4B-A100-000000000001'; |
|||
|
|||
static const int opHello = 0x01; |
|||
static const int opSessionCfg = 0x02; |
|||
static const int opStartStream = 0x03; |
|||
static const int opStopStream = 0x04; |
|||
static const int opKeepalive = 0x05; |
|||
static const int opExit = 0x06; |
|||
static const int opAudioUp = 0x90; |
|||
static const int opAudioDown = 0x91; |
|||
static const int opEvent = 0x87; |
|||
static const int opPace = 0x88; |
|||
|
|||
static const int roleApp = 0x01; |
|||
static const int vadAlways = 0x00; |
|||
static const int triggerContinuous = 0x00; |
|||
static const int defaultGain = 80; |
|||
|
|||
static const int flagVad = 0x10; |
|||
static const int flagSegBegin = 0x20; |
|||
static const int flagSegEnd = 0x40; |
|||
static const int flagOwnVoice = 0x80; |
|||
static const int flagPlayBegin = 0x10; |
|||
static const int flagPlayEnd = 0x20; |
|||
static const int flagFlush = 0x40; |
|||
|
|||
static const int evtButton = 0x01; |
|||
static const int evtWear = 0x02; |
|||
static const int evtBattery = 0x03; |
|||
static const int evtStats = 0x04; |
|||
static const int evtFault = 0x05; |
|||
static const int evtExitRequest = 0x06; |
|||
|
|||
static const int exitUser = 0x01; |
|||
static const int exitBattery = 0x02; |
|||
static const int exitIncomingCall = 0x03; |
|||
static const int exitPeerLost = 0x04; |
|||
|
|||
static const int ok = 0x00; |
|||
static const int errUnsupported = 0x01; |
|||
static const int errBusy = 0x02; |
|||
static const int errEarUnavailable = 0x03; |
|||
static const int errToken = 0x04; |
|||
static const int errBleResource = 0x05; |
|||
static const int errCodec = 0x06; |
|||
static const int errTimeout = 0x07; |
|||
static const int errEarMismatch = 0x08; |
|||
static const int errOverflow = 0x09; |
|||
static const int errInternal = 0x0A; |
|||
static const int errTwsSplit = 0x0B; |
|||
|
|||
/// `AA 6B 06 <ver> <cap> <token 4>` |
|||
static List<int> enterFrame(List<int> token) { |
|||
if (token.length != 4) { |
|||
throw ArgumentError('token must be 4 bytes'); |
|||
} |
|||
return <int>[ |
|||
0xAA, 0x6B, 0x06, protoVer, capG722, |
|||
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, |
|||
]; |
|||
} |
|||
|
|||
static List<int> exitFrame() => const <int>[0xAA, 0x6C]; |
|||
|
|||
/// 解析 `BB EB`。长度不够返回 null。 |
|||
static BesF2fEnterAck? parseEnterAck(List<int> frame) { |
|||
if (frame.length < 10) return null; |
|||
if ((frame[0] & 0xFF) != 0xBB || (frame[1] & 0xFF) != 0xEB) return null; |
|||
return BesF2fEnterAck( |
|||
result: frame[3] & 0xFF, |
|||
earMask: frame[4] & 0xFF, |
|||
protoVer: frame[5] & 0xFF, |
|||
codec: frame[6] & 0xFF, |
|||
frameMs: frame[7] & 0xFF, |
|||
fwMinor: frame[8] & 0xFF, |
|||
classicKeep: (frame[9] & 0xFF) == 0x01, |
|||
); |
|||
} |
|||
|
|||
static bool isExitAckOk(List<int> frame) { |
|||
if (frame.length < 4) return false; |
|||
return (frame[0] & 0xFF) == 0xBB && |
|||
(frame[1] & 0xFF) == 0xEC && |
|||
(frame[3] & 0xFF) == ok; |
|||
} |
|||
|
|||
/// 广播里 Manufacturer Data 去掉 Company ID 之后的 9 字节。 |
|||
static BesF2fAdvert? parseAdvertPayload(List<int> afterCompanyId) { |
|||
if (afterCompanyId.length < 9) return null; |
|||
if ((afterCompanyId[0] & 0xFF) != magic0 || |
|||
(afterCompanyId[1] & 0xFF) != magic1) { |
|||
return null; |
|||
} |
|||
final ear = afterCompanyId[3] & 0xFF; |
|||
if (ear != earLeft && ear != earRight) return null; |
|||
return BesF2fAdvert( |
|||
version: afterCompanyId[2] & 0xFF, |
|||
ear: ear, |
|||
token: List<int>.unmodifiable(afterCompanyId.sublist(4, 8)), |
|||
battery: afterCompanyId[8] & 0x7F, |
|||
charging: (afterCompanyId[8] & 0x80) != 0, |
|||
); |
|||
} |
|||
|
|||
static List<int> hello(List<int> token) => <int>[ |
|||
0xAA, opHello, 0x06, protoVer, |
|||
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, |
|||
roleApp, |
|||
]; |
|||
|
|||
static List<int> sessionCfg() => <int>[ |
|||
0xAA, opSessionCfg, 0x03, vadAlways, defaultGain, triggerContinuous, |
|||
]; |
|||
|
|||
static List<int> startStream() => <int>[0xAA, opStartStream, 0x00]; |
|||
|
|||
static List<int> stopStream() => <int>[0xAA, opStopStream, 0x00]; |
|||
|
|||
static List<int> keepalive(int seq) => |
|||
<int>[0xAA, opKeepalive, 0x01, seq & 0xFF]; |
|||
|
|||
static List<int> exitEar() => <int>[0xAA, opExit, 0x00]; |
|||
|
|||
/// `AA 91` 下行音频。payload = flags(含耳位) + seq16le + g722。 |
|||
static List<int> audioDown({ |
|||
required int ear, |
|||
required int flags, |
|||
required int seq, |
|||
required List<int> g722, |
|||
}) { |
|||
final head = (ear & 0x0F) | (flags & 0xF0); |
|||
final body = <int>[head, seq & 0xFF, (seq >> 8) & 0xFF, ...g722]; |
|||
return <int>[0xAA, opAudioDown, body.length & 0xFF, ...body]; |
|||
} |
|||
|
|||
/// 解析一条 BLE 通知帧。不是本协议返回 null。 |
|||
static BesF2fBleFrame? parseBle(List<int> frame) { |
|||
if (frame.length < 3) return null; |
|||
final sof = frame[0] & 0xFF; |
|||
if (sof != 0xAA && sof != 0xBB) return null; |
|||
final op = frame[1] & 0xFF; |
|||
final len = frame[2] & 0xFF; |
|||
if (frame.length < 3 + len) return null; |
|||
return BesF2fBleFrame(op: op, payload: frame.sublist(3, 3 + len)); |
|||
} |
|||
|
|||
static BesF2fAudio? parseAudioUp(BesF2fBleFrame frame) { |
|||
if (frame.op != opAudioUp || frame.payload.length < 3) return null; |
|||
final b = frame.payload[0] & 0xFF; |
|||
return BesF2fAudio( |
|||
ear: b & 0x0F, |
|||
flags: b & 0xF0, |
|||
seq: (frame.payload[1] & 0xFF) | ((frame.payload[2] & 0xFF) << 8), |
|||
g722: List<int>.unmodifiable(frame.payload.sublist(3)), |
|||
); |
|||
} |
|||
|
|||
/// 用户能看懂的失败文案键。未知码用超时。 |
|||
static String messageKey(int code) { |
|||
switch (code) { |
|||
case errUnsupported: |
|||
return 'f2fFirmwareUpgrade'; |
|||
case errBusy: |
|||
return 'f2fEndOtherFeature'; |
|||
case errEarUnavailable: |
|||
return 'f2fWearBothEars'; |
|||
case errTwsSplit: |
|||
return 'f2fPutBackInCase'; |
|||
default: |
|||
return 'f2fEarNotReady'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
class BesF2fEnterAck { |
|||
const BesF2fEnterAck({ |
|||
required this.result, |
|||
required this.earMask, |
|||
required this.protoVer, |
|||
required this.codec, |
|||
required this.frameMs, |
|||
required this.fwMinor, |
|||
required this.classicKeep, |
|||
}); |
|||
|
|||
final int result; |
|||
final int earMask; |
|||
final int protoVer; |
|||
final int codec; |
|||
final int frameMs; |
|||
final int fwMinor; |
|||
final bool classicKeep; |
|||
|
|||
bool get bothEars => (earMask & 0x03) == 0x03; |
|||
} |
|||
|
|||
class BesF2fAdvert { |
|||
const BesF2fAdvert({ |
|||
required this.version, |
|||
required this.ear, |
|||
required this.token, |
|||
required this.battery, |
|||
required this.charging, |
|||
}); |
|||
|
|||
final int version; |
|||
final int ear; |
|||
final List<int> token; |
|||
final int battery; |
|||
final bool charging; |
|||
|
|||
bool tokenEquals(List<int> expected) { |
|||
if (token.length != expected.length) return false; |
|||
for (var i = 0; i < token.length; i++) { |
|||
if ((token[i] & 0xFF) != (expected[i] & 0xFF)) return false; |
|||
} |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
class BesF2fBleFrame { |
|||
const BesF2fBleFrame({required this.op, required this.payload}); |
|||
final int op; |
|||
final List<int> payload; |
|||
|
|||
int get result => payload.isEmpty ? 0xFF : payload[0] & 0xFF; |
|||
} |
|||
|
|||
class BesF2fAudio { |
|||
const BesF2fAudio({ |
|||
required this.ear, |
|||
required this.flags, |
|||
required this.seq, |
|||
required this.g722, |
|||
}); |
|||
|
|||
final int ear; |
|||
final int flags; |
|||
final int seq; |
|||
final List<int> g722; |
|||
|
|||
bool get ownVoice => (flags & BesF2f.flagOwnVoice) != 0; |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package com.yunqiinnovation.azure_speech |
|||
|
|||
import android.util.Log |
|||
import com.example.bluetooth_manager.BluetoothManager |
|||
import com.example.bluetooth_manager.FaceToFaceBleSession |
|||
|
|||
/** |
|||
* Echo-one 面对面的 PCM 直连桥。和通话翻译的 [BesCallPcmBridge] 分开: |
|||
* 两只耳朵都是真人,不过 B 路门限。 |
|||
* |
|||
* 左耳上行进 A,右耳上行进 B。译文由 [postDownlink] 交给 BLE 会话, |
|||
* 会话里再把 A 写到右耳、B 写到左耳。 |
|||
*/ |
|||
class BesF2fPcmBridge( |
|||
private val onLegA: (ByteArray) -> Unit, |
|||
private val onLegB: (ByteArray) -> Unit, |
|||
) { |
|||
@Volatile |
|||
var isEnabled: Boolean = false |
|||
private set |
|||
|
|||
private val lock = Any() |
|||
private var ready = false |
|||
private val holdA = ArrayDeque<ByteArray>() |
|||
private val holdB = ArrayDeque<ByteArray>() |
|||
|
|||
fun enable() { |
|||
if (isEnabled) return |
|||
isEnabled = true |
|||
BluetoothManager.f2fPcmTap = { leg, pcm -> enqueue(leg, pcm) } |
|||
Log.i(TAG, "面对面桥已开启") |
|||
} |
|||
|
|||
fun disable() { |
|||
if (!isEnabled) return |
|||
isEnabled = false |
|||
BluetoothManager.f2fPcmTap = null |
|||
synchronized(lock) { |
|||
holdA.clear() |
|||
holdB.clear() |
|||
} |
|||
} |
|||
|
|||
fun markReady() { |
|||
val a: List<ByteArray> |
|||
val b: List<ByteArray> |
|||
synchronized(lock) { |
|||
ready = true |
|||
a = holdA.toList() |
|||
b = holdB.toList() |
|||
holdA.clear() |
|||
holdB.clear() |
|||
} |
|||
for (pcm in a) onLegA(pcm) |
|||
for (pcm in b) onLegB(pcm) |
|||
} |
|||
|
|||
fun postDownlink(leg: String, pcm: ByteArray) { |
|||
if (pcm.isEmpty()) return |
|||
FaceToFaceBleSession.pushPcm(leg, pcm) |
|||
} |
|||
|
|||
private fun enqueue(leg: String, pcm: ByteArray) { |
|||
val isA = leg == "A" |
|||
synchronized(lock) { |
|||
if (!ready) { |
|||
val q = if (isA) holdA else holdB |
|||
if (q.size >= 150) q.removeFirst() |
|||
q.addLast(pcm) |
|||
return |
|||
} |
|||
} |
|||
if (isA) onLegA(pcm) else onLegB(pcm) |
|||
} |
|||
|
|||
companion object { |
|||
private const val TAG = "BesF2fPcmBridge" |
|||
} |
|||
} |
|||
@ -0,0 +1,85 @@ |
|||
import Foundation |
|||
|
|||
/// Echo-one 面对面 PCM 直连桥。通知名与 bluetooth_manager 的 FaceToFaceBleSession 对齐, |
|||
/// 两个插件不能互相 import。 |
|||
final class BesF2fPcmBridge { |
|||
static let uplinkNote = Notification.Name("com.eaimar.bes.f2fpcm.uplink") |
|||
static let downlinkNote = Notification.Name("com.eaimar.bes.f2fpcm.downlink") |
|||
|
|||
var onLegA: ((Data) -> Void)? |
|||
var onLegB: ((Data) -> Void)? |
|||
|
|||
private(set) var isEnabled = false |
|||
private var ready = false |
|||
private var holdA: [Data] = [] |
|||
private var holdB: [Data] = [] |
|||
private let lock = NSLock() |
|||
private var observer: NSObjectProtocol? |
|||
|
|||
func enable() { |
|||
if isEnabled { return } |
|||
isEnabled = true |
|||
observer = NotificationCenter.default.addObserver( |
|||
forName: BesF2fPcmBridge.uplinkNote, |
|||
object: nil, |
|||
queue: nil |
|||
) { [weak self] note in |
|||
guard let leg = note.userInfo?["leg"] as? String, |
|||
let pcm = note.userInfo?["pcm"] as? Data else { return } |
|||
self?.enqueue(leg: leg, pcm: pcm) |
|||
} |
|||
} |
|||
|
|||
func disable() { |
|||
if !isEnabled { return } |
|||
isEnabled = false |
|||
if let observer = observer { |
|||
NotificationCenter.default.removeObserver(observer) |
|||
} |
|||
observer = nil |
|||
lock.lock() |
|||
holdA.removeAll() |
|||
holdB.removeAll() |
|||
lock.unlock() |
|||
} |
|||
|
|||
func markReady() { |
|||
lock.lock() |
|||
ready = true |
|||
let a = holdA |
|||
let b = holdB |
|||
holdA.removeAll() |
|||
holdB.removeAll() |
|||
lock.unlock() |
|||
for pcm in a { onLegA?(pcm) } |
|||
for pcm in b { onLegB?(pcm) } |
|||
} |
|||
|
|||
func postDownlink(leg: String, pcm: Data) { |
|||
if pcm.isEmpty { return } |
|||
NotificationCenter.default.post( |
|||
name: BesF2fPcmBridge.downlinkNote, |
|||
object: nil, |
|||
userInfo: ["leg": leg, "pcm": pcm] |
|||
) |
|||
} |
|||
|
|||
private func enqueue(leg: String, pcm: Data) { |
|||
let isA = leg == "A" |
|||
lock.lock() |
|||
let go = ready |
|||
if !go { |
|||
if isA { |
|||
if holdA.count >= 150 { holdA.removeFirst() } |
|||
holdA.append(pcm) |
|||
} else { |
|||
if holdB.count >= 150 { holdB.removeFirst() } |
|||
holdB.append(pcm) |
|||
} |
|||
} |
|||
lock.unlock() |
|||
if go { |
|||
if isA { onLegA?(pcm) } else { onLegB?(pcm) } |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,950 @@ |
|||
package com.example.bluetooth_manager |
|||
|
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothDevice |
|||
import android.bluetooth.BluetoothGatt |
|||
import android.bluetooth.BluetoothGattCallback |
|||
import android.bluetooth.BluetoothGattCharacteristic |
|||
import android.bluetooth.BluetoothGattDescriptor |
|||
import android.bluetooth.BluetoothProfile |
|||
import android.bluetooth.le.ScanCallback |
|||
import android.bluetooth.le.ScanFilter |
|||
import android.bluetooth.le.ScanResult |
|||
import android.bluetooth.le.ScanSettings |
|||
import android.content.Context |
|||
import android.os.Build |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.os.ParcelUuid |
|||
import android.os.SystemClock |
|||
import android.util.Log |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import java.security.SecureRandom |
|||
import java.util.UUID |
|||
import java.util.concurrent.Executors |
|||
import java.util.concurrent.ScheduledFuture |
|||
import java.util.concurrent.TimeUnit |
|||
import kotlin.math.max |
|||
import kotlin.math.min |
|||
import kotlin.math.roundToInt |
|||
import kotlin.math.sqrt |
|||
|
|||
/** |
|||
* Echo-one 面对面:经典链路发 AA 6B 拆 TWS,左右耳各一条 BLE。 |
|||
* |
|||
* 左耳上行 → leg A,右耳上行 → leg B;译文 A 播右耳、译文 B 播左耳。 |
|||
* 字节与 [docs/face_to_face_ble_protocol.md] 以及 Dart `BesF2f` 一致。 |
|||
* 不碰通话翻译 / AI 的发送器。 |
|||
*/ |
|||
object FaceToFaceBleSession { |
|||
private const val TAG = "BesF2f" |
|||
private const val PREFS = "bes_f2f" |
|||
private const val KEY_PENDING_EXIT = "pending_classic_exit" |
|||
|
|||
private val SERVICE: UUID = UUID.fromString("AEAF5F32-0001-4C4B-A100-000000000001") |
|||
private val CTRL_TX: UUID = UUID.fromString("AEAF5F32-0002-4C4B-A100-000000000001") |
|||
private val CTRL_RX: UUID = UUID.fromString("AEAF5F32-0003-4C4B-A100-000000000001") |
|||
private val AUDIO_UP: UUID = UUID.fromString("AEAF5F32-0004-4C4B-A100-000000000001") |
|||
private val AUDIO_DOWN: UUID = UUID.fromString("AEAF5F32-0005-4C4B-A100-000000000001") |
|||
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") |
|||
|
|||
private val main = Handler(Looper.getMainLooper()) |
|||
private val random = SecureRandom() |
|||
private val lock = Object() |
|||
|
|||
@Volatile var eventEmitter: ((Map<String, Any?>) -> Unit)? = null |
|||
|
|||
private var result: MethodChannel.Result? = null |
|||
private var token = ByteArray(4) |
|||
private var active = false |
|||
private var entered = false |
|||
private var classicKeep = true |
|||
private var frameMs = 40 |
|||
private val ears = HashMap<Int, Ear>() |
|||
private var scanCallback: ScanCallback? = null |
|||
private var scanTimer: Runnable? = null |
|||
private var enterAck: ByteArray? = null |
|||
private val downlink = F2fDownlink() |
|||
private var keepaliveSeq = 0 |
|||
private var keepaliveTask: ScheduledFuture<*>? = null |
|||
private val scheduler = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "bes.f2f") } |
|||
|
|||
fun start(flutterResult: MethodChannel.Result) { |
|||
synchronized(lock) { |
|||
if (active) { |
|||
main.post { flutterResult.success(fail(0x02, "busy")) } |
|||
return |
|||
} |
|||
if (!BluetoothManager.isConnected) { |
|||
main.post { flutterResult.success(fail(0x07, "classic down")) } |
|||
return |
|||
} |
|||
active = true |
|||
entered = false |
|||
result = flutterResult |
|||
ears.clear() |
|||
token = ByteArray(4).also { random.nextBytes(it) } |
|||
enterAck = null |
|||
} |
|||
val frame = byteArrayOf( |
|||
0xAA.toByte(), 0x6B.toByte(), 0x06, 0x02, 0x01, |
|||
token[0], token[1], token[2], token[3], |
|||
) |
|||
BluetoothManager.writeBytes(frame) |
|||
scheduler.schedule({ |
|||
synchronized(lock) { |
|||
if (!active || enterAck != null) return@schedule |
|||
} |
|||
failStart(0x07, "no BB EB") |
|||
}, 3, TimeUnit.SECONDS) |
|||
} |
|||
|
|||
fun stop() { |
|||
val keep: Boolean |
|||
val wasEntered: Boolean |
|||
synchronized(lock) { |
|||
if (!active && ears.isEmpty()) { |
|||
sendClassicExitIfUp() |
|||
return |
|||
} |
|||
keep = classicKeep |
|||
wasEntered = entered |
|||
active = false |
|||
} |
|||
teardownLinks() |
|||
if (wasEntered) { |
|||
if (BluetoothManager.isConnected) { |
|||
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte())) |
|||
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, false)?.apply() |
|||
} else if (!keep) { |
|||
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, true)?.apply() |
|||
} |
|||
} |
|||
emit("exited", emptyMap()) |
|||
} |
|||
|
|||
fun onClassicFrame(data: ByteArray) { |
|||
if (data.size < 2) return |
|||
val cmd = data[1].toInt() and 0xFF |
|||
if (cmd == 0xEB) { |
|||
synchronized(lock) { enterAck = data } |
|||
onEnterAck(data) |
|||
} |
|||
} |
|||
|
|||
/** 经典链路重新连上。上次退出时链路已经断了,补一次幂等的 AA 6C。 */ |
|||
fun onClassicLinkUp() { |
|||
if (active) return |
|||
val p = prefs() ?: return |
|||
if (!p.getBoolean(KEY_PENDING_EXIT, false)) return |
|||
p.edit().putBoolean(KEY_PENDING_EXIT, false).apply() |
|||
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte())) |
|||
Log.i(TAG, "回连后补发 AA 6C") |
|||
} |
|||
|
|||
/** AST 译文。leg A 播给右耳,leg B 播给左耳。 */ |
|||
fun pushPcm(leg: String, pcm: ByteArray) { |
|||
if (!active || pcm.isEmpty()) return |
|||
val ear = if (leg == "A") 2 else 1 |
|||
downlink.offer(ear, pcm) |
|||
} |
|||
|
|||
private fun onEnterAck(data: ByteArray) { |
|||
if (data.size < 10) { |
|||
failStart(0x0A, "short BB EB") |
|||
return |
|||
} |
|||
val code = data[3].toInt() and 0xFF |
|||
if (code != 0) { |
|||
failStart(code, "BB EB $code") |
|||
return |
|||
} |
|||
val mask = data[4].toInt() and 0xFF |
|||
if ((mask and 0x03) != 0x03) { |
|||
failStart(0x03, "ear mask $mask") |
|||
return |
|||
} |
|||
frameMs = data[7].toInt() and 0xFF |
|||
if (frameMs != 20 && frameMs != 40) frameMs = 40 |
|||
classicKeep = (data[9].toInt() and 0xFF) == 0x01 |
|||
entered = true |
|||
if (!classicKeep) { |
|||
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, true)?.apply() |
|||
} |
|||
emit("entered", mapOf("classicKeep" to classicKeep, "frameMs" to frameMs)) |
|||
startScan() |
|||
} |
|||
|
|||
private fun startScan() { |
|||
val adapter = BluetoothAdapter.getDefaultAdapter() |
|||
val scanner = adapter?.bluetoothLeScanner |
|||
if (scanner == null) { |
|||
failStart(0x05, "no scanner") |
|||
return |
|||
} |
|||
val cb = object : ScanCallback() { |
|||
override fun onScanResult(callbackType: Int, result: ScanResult) { |
|||
consider(result) |
|||
} |
|||
|
|||
override fun onScanFailed(errorCode: Int) { |
|||
failStart(0x05, "scan $errorCode") |
|||
} |
|||
} |
|||
scanCallback = cb |
|||
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build() |
|||
val settings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
|||
.build() |
|||
try { |
|||
scanner.startScan(listOf(filter), settings, cb) |
|||
} catch (e: SecurityException) { |
|||
failStart(0x05, "scan permission") |
|||
return |
|||
} |
|||
emit("scanning", emptyMap()) |
|||
val timeout = Runnable { |
|||
val have = synchronized(lock) { ears.keys.toSet() } |
|||
if (!have.contains(1) || !have.contains(2)) { |
|||
failStart(0x03, "scan timeout ears=$have") |
|||
} |
|||
} |
|||
scanTimer = timeout |
|||
main.postDelayed(timeout, 15_000) |
|||
} |
|||
|
|||
private fun consider(result: ScanResult) { |
|||
val record = result.scanRecord ?: return |
|||
val ad = record.manufacturerSpecificData ?: return |
|||
var parsed: Adv? = null |
|||
for (i in 0 until ad.size()) { |
|||
parsed = parseAdv(ad.valueAt(i)) ?: continue |
|||
break |
|||
} |
|||
val adv = parsed ?: return |
|||
if (!adv.token.contentEquals(token)) return |
|||
if (adv.version != 0x02) return |
|||
synchronized(lock) { |
|||
if (!active) return |
|||
if (ears.containsKey(adv.ear)) return |
|||
if (ears.size >= 2) return |
|||
ears[adv.ear] = Ear(adv.ear, result.device) |
|||
val ready = ears.containsKey(1) && ears.containsKey(2) |
|||
if (!ready) return |
|||
} |
|||
stopScan() |
|||
connectBoth() |
|||
} |
|||
|
|||
private fun parseAdv(payload: ByteArray?): Adv? { |
|||
if (payload == null || payload.size < 9) return null |
|||
if ((payload[0].toInt() and 0xFF) != 0xF2 || (payload[1].toInt() and 0xFF) != 0xF2) return null |
|||
val ear = payload[3].toInt() and 0xFF |
|||
if (ear != 1 && ear != 2) return null |
|||
return Adv(payload[2].toInt() and 0xFF, ear, payload.copyOfRange(4, 8)) |
|||
} |
|||
|
|||
private fun connectBoth() { |
|||
emit("connecting", emptyMap()) |
|||
val list = synchronized(lock) { ears.values.toList() } |
|||
for (ear in list) ear.connect() |
|||
scheduler.schedule({ |
|||
if (!active) return@schedule |
|||
val pending = synchronized(lock) { ears.values.any { !it.handshook } } |
|||
if (pending) failStart(0x07, "handshake timeout") |
|||
}, 12, TimeUnit.SECONDS) |
|||
} |
|||
|
|||
private fun onEarHandshook(ear: Int) { |
|||
val both = synchronized(lock) { ears.values.all { it.handshook } && ears.size == 2 } |
|||
if (!both) return |
|||
stopScan() |
|||
downlink.start(frameMs) |
|||
keepaliveSeq = 0 |
|||
keepaliveTask?.cancel(false) |
|||
keepaliveTask = scheduler.scheduleAtFixedRate({ |
|||
if (!active) return@scheduleAtFixedRate |
|||
val seq = keepaliveSeq++ and 0xFF |
|||
for (e in synchronized(lock) { ears.values.toList() }) { |
|||
e.writeCtrl(byteArrayOf(0xAA.toByte(), 0x05, 0x01, seq.toByte())) |
|||
} |
|||
}, 5, 5, TimeUnit.SECONDS) |
|||
val ack = synchronized(lock) { enterAck } |
|||
val map = hashMapOf<String, Any>( |
|||
"ok" to true, |
|||
"classicKeep" to classicKeep, |
|||
"frameMs" to frameMs, |
|||
"fwMinor" to ((ack?.getOrNull(8)?.toInt() ?: 0) and 0xFF), |
|||
) |
|||
succeed(map) |
|||
emit("ready", map) |
|||
Log.i(TAG, "双耳握手完成 frameMs=$frameMs classicKeep=$classicKeep") |
|||
} |
|||
|
|||
fun onUplink(ear: Int, pcm: ByteArray) { |
|||
val tap = BluetoothManager.f2fPcmTap ?: return |
|||
if (pcm.isEmpty()) return |
|||
tap.invoke(if (ear == 1) "A" else "B", pcm) |
|||
} |
|||
|
|||
fun onEarLost(ear: Int) { |
|||
if (!active) return |
|||
emit("earLost", mapOf("ear" to ear)) |
|||
// 一方听不到了就停整场,10 秒内由 Dart 决定要不要重进。这里先完整退出。 |
|||
failStart(0x07, "ear $ear lost") |
|||
} |
|||
|
|||
private fun succeed(map: Map<String, Any>) { |
|||
val r = synchronized(lock) { val x = result; result = null; x } ?: return |
|||
main.post { r.success(map) } |
|||
} |
|||
|
|||
private fun failStart(code: Int, why: String) { |
|||
Log.w(TAG, "失败 $code $why") |
|||
val wasActive = synchronized(lock) { active } |
|||
if (!wasActive && result == null) return |
|||
synchronized(lock) { active = false } |
|||
teardownLinks() |
|||
if (entered && BluetoothManager.isConnected) { |
|||
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte())) |
|||
} |
|||
val r = synchronized(lock) { val x = result; result = null; x } |
|||
emit("error", mapOf("code" to code, "message" to why)) |
|||
if (r != null) main.post { r.success(fail(code, why)) } |
|||
} |
|||
|
|||
private fun teardownLinks() { |
|||
stopScan() |
|||
keepaliveTask?.cancel(false) |
|||
keepaliveTask = null |
|||
downlink.stop() |
|||
val list = synchronized(lock) { |
|||
val snapshot = ears.values.toList() |
|||
ears.clear() |
|||
snapshot |
|||
} |
|||
for (ear in list) { |
|||
ear.handshook = false |
|||
ear.disconnect() |
|||
ear.release() |
|||
} |
|||
} |
|||
|
|||
private fun stopScan() { |
|||
scanTimer?.let { main.removeCallbacks(it) } |
|||
scanTimer = null |
|||
val cb = scanCallback ?: return |
|||
scanCallback = null |
|||
try { |
|||
BluetoothAdapter.getDefaultAdapter()?.bluetoothLeScanner?.stopScan(cb) |
|||
} catch (_: SecurityException) { |
|||
} |
|||
} |
|||
|
|||
private fun sendClassicExitIfUp() { |
|||
if (BluetoothManager.isConnected) { |
|||
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte())) |
|||
} |
|||
} |
|||
|
|||
private fun prefs() = try { |
|||
BluetoothManager.appContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
|||
} catch (_: Exception) { |
|||
null |
|||
} |
|||
|
|||
private fun emit(type: String, extra: Map<String, Any?>) { |
|||
val m = HashMap<String, Any?>(extra) |
|||
m["type"] = type |
|||
val sink = eventEmitter ?: return |
|||
main.post { sink(m) } |
|||
} |
|||
|
|||
private fun fail(code: Int, message: String) = mapOf( |
|||
"ok" to false, "code" to code, "message" to message, |
|||
) |
|||
|
|||
private data class Adv(val version: Int, val ear: Int, val token: ByteArray) |
|||
|
|||
private class Ear(val ear: Int, val device: BluetoothDevice) : BluetoothGattCallback() { |
|||
var gatt: BluetoothGatt? = null |
|||
var ctrlTx: BluetoothGattCharacteristic? = null |
|||
var audioDown: BluetoothGattCharacteristic? = null |
|||
var handshook = false |
|||
var seqExpected = -1 |
|||
var seenOwn = false |
|||
private var notifyStep = 0 |
|||
|
|||
fun connect() { |
|||
try { |
|||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
|||
device.connectGatt(BluetoothManager.appContext(), false, this, BluetoothDevice.TRANSPORT_LE) |
|||
} else { |
|||
device.connectGatt(BluetoothManager.appContext(), false, this) |
|||
} |
|||
} catch (e: SecurityException) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
} |
|||
} |
|||
|
|||
fun disconnect() { |
|||
try { |
|||
writeCtrl(byteArrayOf(0xAA.toByte(), 0x06, 0x00)) |
|||
} catch (_: Exception) { |
|||
} |
|||
try { |
|||
gatt?.disconnect() |
|||
gatt?.close() |
|||
} catch (_: SecurityException) { |
|||
} |
|||
gatt = null |
|||
} |
|||
|
|||
fun writeCtrl(bytes: ByteArray) { |
|||
main.post { writeNow(ctrlTx, bytes) } |
|||
} |
|||
|
|||
fun writeAudio(bytes: ByteArray) { |
|||
main.post { writeNow(audioDown, bytes) } |
|||
} |
|||
|
|||
private fun writeNow(c: BluetoothGattCharacteristic?, bytes: ByteArray) { |
|||
val ch = c ?: return |
|||
val g = gatt ?: return |
|||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE |
|||
ch.value = bytes |
|||
try { |
|||
g.writeCharacteristic(ch) |
|||
} catch (_: SecurityException) { |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { |
|||
if (newState == BluetoothProfile.STATE_CONNECTED) { |
|||
try { |
|||
g.requestMtu(247) |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
|||
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) |
|||
} |
|||
} catch (_: SecurityException) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
} |
|||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { |
|||
if (handshook) FaceToFaceBleSession.onEarLost(ear) |
|||
} |
|||
} |
|||
|
|||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { |
|||
try { |
|||
g.discoverServices() |
|||
} catch (_: SecurityException) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
} |
|||
} |
|||
|
|||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { |
|||
val svc = g.getService(SERVICE) |
|||
if (svc == null) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
return |
|||
} |
|||
ctrlTx = svc.getCharacteristic(CTRL_TX) |
|||
audioDown = svc.getCharacteristic(AUDIO_DOWN) |
|||
val rx = svc.getCharacteristic(CTRL_RX) |
|||
val up = svc.getCharacteristic(AUDIO_UP) |
|||
if (ctrlTx == null || audioDown == null || rx == null || up == null) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
return |
|||
} |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
try { |
|||
g.setPreferredPhy( |
|||
BluetoothDevice.PHY_LE_2M_MASK, |
|||
BluetoothDevice.PHY_LE_2M_MASK, |
|||
BluetoothDevice.PHY_OPTION_NO_PREFERRED, |
|||
) |
|||
} catch (_: SecurityException) { |
|||
} |
|||
} |
|||
notifyStep = 0 |
|||
enableNotify(g, rx) |
|||
} |
|||
|
|||
private fun enableNotify(g: BluetoothGatt, c: BluetoothGattCharacteristic) { |
|||
try { |
|||
g.setCharacteristicNotification(c, true) |
|||
val d = c.getDescriptor(CCCD) |
|||
if (d == null) { |
|||
nextNotify(g) |
|||
return |
|||
} |
|||
d.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
g.writeDescriptor(d) |
|||
} catch (_: SecurityException) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
} |
|||
} |
|||
|
|||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { |
|||
nextNotify(g) |
|||
} |
|||
|
|||
private fun nextNotify(g: BluetoothGatt) { |
|||
notifyStep++ |
|||
if (notifyStep == 1) { |
|||
val up = g.getService(SERVICE)?.getCharacteristic(AUDIO_UP) ?: return |
|||
enableNotify(g, up) |
|||
return |
|||
} |
|||
sendHello() |
|||
} |
|||
|
|||
private fun sendHello() { |
|||
val t = token |
|||
writeCtrl(byteArrayOf( |
|||
0xAA.toByte(), 0x01, 0x06, 0x02, t[0], t[1], t[2], t[3], 0x01, |
|||
)) |
|||
} |
|||
|
|||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { |
|||
val v = characteristic.value ?: return |
|||
if (characteristic.uuid == AUDIO_UP) onAudio(v) else onCtrl(v) |
|||
} |
|||
|
|||
private fun onCtrl(frame: ByteArray) { |
|||
if (frame.size < 3) return |
|||
val op = frame[1].toInt() and 0xFF |
|||
val len = frame[2].toInt() and 0xFF |
|||
if (frame.size < 3 + len) return |
|||
val p = frame.copyOfRange(3, 3 + len) |
|||
when (op) { |
|||
0x81 -> { |
|||
if (p.isEmpty() || (p[0].toInt() and 0xFF) != 0) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
return |
|||
} |
|||
writeCtrl(byteArrayOf(0xAA.toByte(), 0x02, 0x03, 0x00, 80, 0x00)) |
|||
} |
|||
0x82 -> writeCtrl(byteArrayOf(0xAA.toByte(), 0x03, 0x00)) |
|||
0x83 -> { |
|||
if (p.isNotEmpty() && (p[0].toInt() and 0xFF) != 0) { |
|||
FaceToFaceBleSession.onEarLost(ear) |
|||
return |
|||
} |
|||
handshook = true |
|||
FaceToFaceBleSession.onEarHandshook(ear) |
|||
} |
|||
0x87 -> onEvent(p) |
|||
0x88 -> if (p.isNotEmpty()) downlink.onPace(ear, p[0].toInt()) |
|||
} |
|||
} |
|||
|
|||
private fun onEvent(p: ByteArray) { |
|||
if (p.isEmpty()) return |
|||
when (p[0].toInt() and 0xFF) { |
|||
0x01 -> if (p.size >= 3) emit("button", mapOf( |
|||
"ear" to ear, |
|||
"key" to (p[1].toInt() and 0xFF), |
|||
"action" to (p[2].toInt() and 0xFF), |
|||
)) |
|||
0x02 -> if (p.size >= 2) emit("wear", mapOf( |
|||
"ear" to ear, "state" to (p[1].toInt() and 0xFF), |
|||
)) |
|||
0x06 -> { |
|||
val reason = if (p.size >= 2) p[1].toInt() and 0xFF else 0 |
|||
emit("exitRequest", mapOf("ear" to ear, "reason" to reason)) |
|||
if (reason == 0x03) FaceToFaceBleSession.failStart(0x02, "incoming call") |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun onAudio(frame: ByteArray) { |
|||
if (frame.size < 6) return |
|||
if ((frame[1].toInt() and 0xFF) != 0x90) return |
|||
val len = frame[2].toInt() and 0xFF |
|||
if (frame.size < 3 + len || len < 3) return |
|||
val flags = frame[3].toInt() and 0xFF |
|||
val earBits = flags and 0x0F |
|||
if (earBits != ear) return |
|||
val seq = (frame[4].toInt() and 0xFF) or ((frame[5].toInt() and 0xFF) shl 8) |
|||
val g722 = frame.copyOfRange(6, 3 + len) |
|||
val own = (flags and 0x80) != 0 |
|||
if (own) seenOwn = true |
|||
if (seenOwn && !own) return |
|||
val chunks = g722.size / 40 |
|||
if (seqExpected >= 0) { |
|||
var gap = (seq - seqExpected) and 0xFFFF |
|||
if (gap in 1..4) { |
|||
val silence = ByteArray(640) |
|||
repeat(gap) { FaceToFaceBleSession.onUplink(ear, silence) } |
|||
} |
|||
} |
|||
seqExpected = (seq + max(chunks, 1)) and 0xFFFF |
|||
if (g722Dec == 0L) g722Dec = G722Codec.init(16000) |
|||
var off = 0 |
|||
while (off + 40 <= g722.size) { |
|||
val chunk = g722.copyOfRange(off, off + 40) |
|||
val pcm = G722Codec.decodeSync(g722Dec, chunk) ?: break |
|||
FaceToFaceBleSession.onUplink(ear, pcm) |
|||
off += 40 |
|||
} |
|||
} |
|||
|
|||
private var g722Dec = 0L |
|||
|
|||
fun release() { |
|||
if (g722Dec != 0L) { |
|||
G722Codec.release(g722Dec) |
|||
g722Dec = 0L |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 每耳一条 40ms 节拍的下行。积压超过 0.5s 裁掉超长静音;超过 1s 用 WSOLA 最高 1.3 倍追。 |
|||
* 起播先垫 4 包编码静音,断句后续 500ms 静音。新一句到来且上一句还在队列里时置 FLUSH。 |
|||
*/ |
|||
private class F2fDownlink { |
|||
private val legs = arrayOf(Leg(1), Leg(2)) |
|||
private var task: ScheduledFuture<*>? = null |
|||
private var packetMs = 40 |
|||
|
|||
fun start(ms: Int) { |
|||
stop() |
|||
packetMs = if (ms == 20) 20 else 40 |
|||
for (l in legs) l.reset() |
|||
task = FaceToFaceBleSession.scheduler.scheduleAtFixedRate({ tick() }, 0, packetMs.toLong(), TimeUnit.MILLISECONDS) |
|||
} |
|||
|
|||
fun stop() { |
|||
task?.cancel(false) |
|||
task = null |
|||
for (l in legs) l.release() |
|||
} |
|||
|
|||
fun offer(ear: Int, pcm: ByteArray) { |
|||
legs.firstOrNull { it.ear == ear }?.offer(pcm) |
|||
} |
|||
|
|||
fun onPace(ear: Int, adj: Int) { |
|||
val leg = legs.firstOrNull { it.ear == ear } ?: return |
|||
when (adj.toByte().toInt()) { |
|||
1 -> leg.extraPackets++ |
|||
-1 -> leg.skipPackets++ |
|||
0 -> leg.restart() |
|||
} |
|||
} |
|||
|
|||
private fun tick() { |
|||
if (!FaceToFaceBleSession.active) return |
|||
for (leg in legs) { |
|||
val link = synchronized(FaceToFaceBleSession.lock) { |
|||
FaceToFaceBleSession.ears[leg.ear] |
|||
} ?: continue |
|||
leg.tick(packetMs, link) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private class Leg(val ear: Int) { |
|||
val frames = ArrayDeque<ByteArray>() |
|||
private companion object { |
|||
const val FRAME = 640 |
|||
} |
|||
var enc = 0L |
|||
var seq = 0 |
|||
var playing = false |
|||
var preroll = 0 |
|||
var tail = 0 |
|||
var extraPackets = 0 |
|||
var skipPackets = 0 |
|||
var flushNext = false |
|||
var lastOffer = 0L |
|||
val wsola = Wsola() |
|||
var silencePkt: ByteArray? = null |
|||
|
|||
fun reset() { |
|||
frames.clear() |
|||
wsola.reset() |
|||
playing = false |
|||
preroll = 0 |
|||
tail = 0 |
|||
seq = 0 |
|||
flushNext = false |
|||
if (enc == 0L) enc = G722Codec.init(16000) |
|||
} |
|||
|
|||
fun release() { |
|||
if (enc != 0L) { |
|||
G722Codec.release(enc) |
|||
enc = 0L |
|||
} |
|||
frames.clear() |
|||
} |
|||
|
|||
fun restart() { |
|||
frames.clear() |
|||
wsola.reset() |
|||
playing = false |
|||
preroll = 4 |
|||
} |
|||
|
|||
@Synchronized |
|||
fun offer(pcm: ByteArray) { |
|||
val now = SystemClock.elapsedRealtime() |
|||
if (playing && now - lastOffer > 150 && frames.isNotEmpty()) flushNext = true |
|||
lastOffer = now |
|||
var off = 0 |
|||
while (off + FRAME <= pcm.size) { |
|||
frames.add(pcm.copyOfRange(off, off + FRAME)) |
|||
off += FRAME |
|||
} |
|||
trimSilence() |
|||
} |
|||
|
|||
private fun trimSilence() { |
|||
if (frames.size * 20 < 500) return |
|||
var silence = 0 |
|||
for (f in frames) { |
|||
if (rms(f) < 300) silence += 20 else break |
|||
} |
|||
var drop = silence - 150 |
|||
while (drop >= 20 && frames.isNotEmpty() && rms(frames.first()) < 300) { |
|||
frames.removeFirst() |
|||
drop -= 20 |
|||
} |
|||
} |
|||
|
|||
fun tick(packetMs: Int, earLink: Ear) { |
|||
val n = if (packetMs == 20) 1 else 2 |
|||
if (skipPackets > 0) { |
|||
skipPackets-- |
|||
return |
|||
} |
|||
var packets = 1 + extraPackets |
|||
extraPackets = 0 |
|||
if (packets > 3) packets = 3 |
|||
val backlogMs = frames.size * 20 |
|||
val rate = when { |
|||
backlogMs <= 1000 -> 1.0 |
|||
backlogMs >= 2500 -> 1.3 |
|||
else -> 1.0 + (backlogMs - 1000) / 1500.0 * 0.3 |
|||
} |
|||
repeat(packets) { |
|||
if (preroll > 0 || (!playing && frames.isNotEmpty())) { |
|||
if (!playing) { |
|||
preroll = 4 |
|||
playing = true |
|||
} |
|||
send(earLink, silence(n), playBegin = preroll == 4, flush = false, playEnd = false) |
|||
preroll-- |
|||
return@repeat |
|||
} |
|||
val pcm = pull(n, rate) ?: run { |
|||
if (playing && tail < 500) { |
|||
tail += packetMs |
|||
val end = tail >= 500 |
|||
send(earLink, silence(n), playBegin = false, flush = false, playEnd = end) |
|||
if (end) { |
|||
playing = false |
|||
tail = 0 |
|||
} |
|||
} |
|||
return@repeat |
|||
} |
|||
tail = 0 |
|||
val flush = flushNext |
|||
flushNext = false |
|||
send(earLink, encode(pcm, n), playBegin = false, flush = flush, playEnd = false) |
|||
} |
|||
} |
|||
|
|||
private fun pull(n: Int, rate: Double): ByteArray? { |
|||
if (frames.isEmpty() && wsola.idle()) return null |
|||
val out = ByteArray(FRAME * n) |
|||
var filled = 0 |
|||
while (filled < n) { |
|||
val frame = wsola.next(rate, frames) ?: return if (filled == 0) null else out.copyOf(filled * FRAME) |
|||
System.arraycopy(frame, 0, out, filled * FRAME, min(FRAME, frame.size)) |
|||
filled++ |
|||
} |
|||
return out |
|||
} |
|||
|
|||
private fun encode(pcm: ByteArray, n: Int): ByteArray { |
|||
val out = ByteArray(40 * n) |
|||
var i = 0 |
|||
while (i < n) { |
|||
val encFrame = G722Codec.encodeSync(enc, pcm.copyOfRange(i * FRAME, (i + 1) * FRAME)) |
|||
?: return silence(n) |
|||
System.arraycopy(encFrame, 0, out, i * 40, min(40, encFrame.size)) |
|||
i++ |
|||
} |
|||
return out |
|||
} |
|||
|
|||
private fun silence(n: Int): ByteArray { |
|||
silencePkt?.let { if (it.size == 40 * n) return it } |
|||
val pcm = ByteArray(FRAME * n) |
|||
val s = encode(pcm, n) |
|||
silencePkt = s |
|||
return s |
|||
} |
|||
|
|||
private fun send(link: Ear, g722: ByteArray, playBegin: Boolean, flush: Boolean, playEnd: Boolean) { |
|||
var flags = ear and 0x0F |
|||
if (playBegin) flags = flags or 0x10 |
|||
if (playEnd) flags = flags or 0x20 |
|||
if (flush) flags = flags or 0x40 |
|||
val bodyLen = 3 + g722.size |
|||
val pkt = ByteArray(3 + bodyLen) |
|||
pkt[0] = 0xAA.toByte() |
|||
pkt[1] = 0x91.toByte() |
|||
pkt[2] = bodyLen.toByte() |
|||
pkt[3] = flags.toByte() |
|||
pkt[4] = (seq and 0xFF).toByte() |
|||
pkt[5] = ((seq shr 8) and 0xFF).toByte() |
|||
System.arraycopy(g722, 0, pkt, 6, g722.size) |
|||
seq = (seq + 1) and 0xFFFF |
|||
link.writeAudio(pkt) |
|||
} |
|||
|
|||
private fun rms(pcm: ByteArray): Int { |
|||
var acc = 0.0 |
|||
var i = 0 |
|||
var n = 0 |
|||
while (i + 1 < pcm.size) { |
|||
val s = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8) |
|||
val v = s.toShort().toInt() |
|||
acc += v * v |
|||
n++ |
|||
i += 8 |
|||
} |
|||
if (n == 0) return 0 |
|||
return sqrt(acc / n).toInt() |
|||
} |
|||
} |
|||
|
|||
/** 20ms 输出的 WSOLA。rate=1 时重叠相加还原原信号。 */ |
|||
private class Wsola { |
|||
private val n = 320 |
|||
private val s = 160 |
|||
private val t = 160 |
|||
private var buf = ShortArray(n * 8) |
|||
private var len = 0 |
|||
private var ana = 0.0 |
|||
private var prev = -1 |
|||
private val win = FloatArray(n) { i -> (0.5 - 0.5 * kotlin.math.cos(2 * Math.PI * i / n)).toFloat() } |
|||
private val acc = FloatArray(n) |
|||
|
|||
fun reset() { |
|||
len = 0 |
|||
ana = 0.0 |
|||
prev = -1 |
|||
java.util.Arrays.fill(acc, 0f) |
|||
} |
|||
|
|||
fun idle(): Boolean = len <= 0 |
|||
|
|||
fun next(rate: Double, src: ArrayDeque<ByteArray>): ByteArray? { |
|||
val need = ana.roundToInt() + t + n |
|||
while (len < need && src.isNotEmpty()) append(src.removeFirst()) |
|||
if (len < ana.roundToInt() + n) return null |
|||
val nominal = ana.roundToInt().coerceIn(0, max(0, len - n)) |
|||
val start = pick(nominal) |
|||
var i = 0 |
|||
while (i < n) { |
|||
acc[i] += win[i] * buf[start + i] |
|||
i++ |
|||
} |
|||
val pcm = ByteArray(n * 2) |
|||
var j = 0 |
|||
while (j < s) { |
|||
val v = acc[j].roundToInt().coerceIn(-32768, 32767) |
|||
pcm[j * 2] = (v and 0xFF).toByte() |
|||
pcm[j * 2 + 1] = ((v shr 8) and 0xFF).toByte() |
|||
j++ |
|||
} |
|||
// 只吐出前 10ms 的话一帧就少一半。合成步长是 10ms,要两次才够 20ms。 |
|||
System.arraycopy(acc, s, acc, 0, n - s) |
|||
java.util.Arrays.fill(acc, n - s, n, 0f) |
|||
prev = start + s |
|||
ana += s * rate |
|||
val start2 = pick(ana.roundToInt().coerceIn(0, max(0, len - n))) |
|||
i = 0 |
|||
while (i < n && start2 + i < len) { |
|||
acc[i] += win[i] * buf[start2 + i] |
|||
i++ |
|||
} |
|||
j = 0 |
|||
while (j < s) { |
|||
val v = acc[j].roundToInt().coerceIn(-32768, 32767) |
|||
val o = (s + j) * 2 |
|||
pcm[o] = (v and 0xFF).toByte() |
|||
pcm[o + 1] = ((v shr 8) and 0xFF).toByte() |
|||
j++ |
|||
} |
|||
System.arraycopy(acc, s, acc, 0, n - s) |
|||
java.util.Arrays.fill(acc, n - s, n, 0f) |
|||
prev = start2 + s |
|||
ana += s * rate |
|||
compact() |
|||
return pcm |
|||
} |
|||
|
|||
private fun pick(nominal: Int): Int { |
|||
if (prev < 0 || prev + s > len) return nominal |
|||
var best = 0 |
|||
var bestScore = Double.NEGATIVE_INFINITY |
|||
val lo = max(-t, -nominal) |
|||
val hi = min(t, len - n - nominal) |
|||
var d = lo |
|||
while (d <= hi) { |
|||
val c = nominal + d |
|||
var xy = 0.0 |
|||
var yy = 1e-6 |
|||
var k = 0 |
|||
while (k < s) { |
|||
val x = buf[prev + k].toDouble() |
|||
val y = buf[c + k].toDouble() |
|||
xy += x * y |
|||
yy += y * y |
|||
k++ |
|||
} |
|||
val score = xy / sqrt(yy) |
|||
if (score > bestScore) { |
|||
bestScore = score |
|||
best = d |
|||
} |
|||
d++ |
|||
} |
|||
return nominal + best |
|||
} |
|||
|
|||
private fun append(block: ByteArray) { |
|||
val samples = block.size / 2 |
|||
ensure(len + samples) |
|||
var i = 0 |
|||
while (i < samples) { |
|||
val v = (block[i * 2].toInt() and 0xFF) or (block[i * 2 + 1].toInt() shl 8) |
|||
buf[len + i] = v.toShort() |
|||
i++ |
|||
} |
|||
len += samples |
|||
} |
|||
|
|||
private fun ensure(cap: Int) { |
|||
if (buf.size < cap) buf = buf.copyOf(max(cap, buf.size * 2)) |
|||
} |
|||
|
|||
private fun compact() { |
|||
val keep = min(ana.roundToInt(), if (prev < 0) ana.roundToInt() else prev) - t - n |
|||
if (keep > n * 4) { |
|||
System.arraycopy(buf, keep, buf, 0, len - keep) |
|||
len -= keep |
|||
ana -= keep |
|||
prev -= keep |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,510 @@ |
|||
import CoreBluetooth |
|||
import Flutter |
|||
import Foundation |
|||
import os.log |
|||
|
|||
/// Echo-one 面对面:第二只耳朵只能走 BLE。和通话翻译的 CallPcmBridge 分开。 |
|||
/// |
|||
/// 左耳上行 → 通知 leg=A,右耳上行 → leg=B。下行通知 A 写右耳、B 写左耳。 |
|||
/// 这只 central 不带 restore identifier,名额留给经典蓝牙那只。 |
|||
final class FaceToFaceBleSession: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { |
|||
static let shared = FaceToFaceBleSession() |
|||
|
|||
static let uplinkNote = Notification.Name("com.eaimar.bes.f2fpcm.uplink") |
|||
static let downlinkNote = Notification.Name("com.eaimar.bes.f2fpcm.downlink") |
|||
|
|||
private let svc = CBUUID(string: "AEAF5F32-0001-4C4B-A100-000000000001") |
|||
private let ctrlTxUUID = CBUUID(string: "AEAF5F32-0002-4C4B-A100-000000000001") |
|||
private let ctrlRxUUID = CBUUID(string: "AEAF5F32-0003-4C4B-A100-000000000001") |
|||
private let audioUpUUID = CBUUID(string: "AEAF5F32-0004-4C4B-A100-000000000001") |
|||
private let audioDownUUID = CBUUID(string: "AEAF5F32-0005-4C4B-A100-000000000001") |
|||
private let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB") |
|||
|
|||
private let queue = DispatchQueue(label: "com.eaimar.f2f.ble") |
|||
private var central: CBCentralManager? |
|||
private var token = Data() |
|||
private var frameMs = 40 |
|||
private var classicKeep = true |
|||
private var active = false |
|||
private var result: FlutterResult? |
|||
private var ears: [Int: Ear] = [:] |
|||
private var seen = Set<Int>() |
|||
private var scanTimer: DispatchSourceTimer? |
|||
private var ackTimer: DispatchSourceTimer? |
|||
private var keepalive: DispatchSourceTimer? |
|||
private let downlink = Downlink() |
|||
var eventSink: FlutterEventSink? |
|||
|
|||
private let pendingKey = "bes_f2f_pending_classic_exit" |
|||
private let log = OSLog(subsystem: "com.eaimar.bluetooth", category: "f2f") |
|||
|
|||
func start(_ result: @escaping FlutterResult) { |
|||
queue.async { |
|||
if self.active { |
|||
self.finish(result, ok: false, code: 2, message: "面对面翻译已经在进行") |
|||
return |
|||
} |
|||
guard BluetoothManager.sharedInstance?.isConnected == true else { |
|||
self.finish(result, ok: false, code: 7, message: "请先连接耳机") |
|||
return |
|||
} |
|||
var raw = [UInt8](repeating: 0, count: 4) |
|||
_ = SecRandomCopyBytes(kSecRandomDefault, 4, &raw) |
|||
self.token = Data(raw) |
|||
self.active = true |
|||
self.result = result |
|||
self.classicKeep = true |
|||
self.frameMs = 40 |
|||
var frame = Data([0xAA, 0x6B, 0x06, 0x02, 0x01]) |
|||
frame.append(self.token) |
|||
BluetoothManager.writeBytes(frame) |
|||
self.armAck() |
|||
} |
|||
} |
|||
|
|||
func stop() { |
|||
queue.async { self.shutdown(sendExit: true) } |
|||
} |
|||
|
|||
func onClassicFrame(_ data: Data) { |
|||
queue.async { |
|||
guard data.count >= 4, data[0] == 0xBB else { return } |
|||
if data[1] == 0xEB { self.onEnterAck(data) } |
|||
if data[1] == 0xEC { self.onExitAck(data) } |
|||
} |
|||
} |
|||
|
|||
func onClassicLinkUp() { |
|||
queue.async { |
|||
guard UserDefaults.standard.bool(forKey: self.pendingKey), !self.active else { return } |
|||
BluetoothManager.writeBytes(Data([0xAA, 0x6C])) |
|||
} |
|||
} |
|||
|
|||
func pushDownlink(leg: String, pcm: Data) { |
|||
let ear = leg == "A" ? 2 : 1 |
|||
downlink.offer(ear: ear, pcm: pcm) |
|||
} |
|||
|
|||
private func onEnterAck(_ data: Data) { |
|||
ackTimer?.cancel() |
|||
ackTimer = nil |
|||
guard active, result != nil else { return } |
|||
let code = data.count > 3 ? Int(data[3]) : 0xFF |
|||
guard code == 0 else { |
|||
fail(code: code, message: "耳机拒绝进入面对面") |
|||
return |
|||
} |
|||
let mask = data.count > 4 ? Int(data[4]) : 0 |
|||
guard mask & 0x03 == 0x03 else { |
|||
fail(code: 3, message: "请确认双耳都已取出并佩戴") |
|||
return |
|||
} |
|||
if data.count > 7, data[7] == 20 { frameMs = 20 } |
|||
classicKeep = data.count <= 9 || data[9] != 0 |
|||
if !classicKeep { |
|||
UserDefaults.standard.set(true, forKey: pendingKey) |
|||
} |
|||
emit(["type": "scanning"]) |
|||
startScan() |
|||
} |
|||
|
|||
private func onExitAck(_ data: Data) { |
|||
if data.count > 3, data[3] == 0 { |
|||
UserDefaults.standard.set(false, forKey: pendingKey) |
|||
} |
|||
} |
|||
|
|||
private func startScan() { |
|||
if central == nil { |
|||
central = CBCentralManager(delegate: self, queue: queue) |
|||
} else if central?.state == .poweredOn { |
|||
beginScan() |
|||
} |
|||
} |
|||
|
|||
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|||
if central.state == .poweredOn, active, ears.count < 2 { |
|||
beginScan() |
|||
} |
|||
} |
|||
|
|||
private func beginScan() { |
|||
central?.scanForPeripherals(withServices: [svc], options: [ |
|||
CBCentralManagerScanOptionAllowDuplicatesKey: true |
|||
]) |
|||
scanTimer?.cancel() |
|||
let timer = DispatchSource.makeTimerSource(queue: queue) |
|||
timer.schedule(deadline: .now() + 15) |
|||
timer.setEventHandler { [weak self] in self?.fail(code: 5, message: "没有找到双耳") } |
|||
timer.resume() |
|||
scanTimer = timer |
|||
} |
|||
|
|||
func centralManager( |
|||
_ central: CBCentralManager, |
|||
didDiscover peripheral: CBPeripheral, |
|||
advertisementData: [String: Any], |
|||
rssi RSSI: NSNumber |
|||
) { |
|||
guard let parsed = parseAdv(advertisementData) else { return } |
|||
guard parsed.ver == 0x02, parsed.token == token, parsed.ear == 1 || parsed.ear == 2 else { return } |
|||
if seen.contains(parsed.ear) { return } |
|||
seen.insert(parsed.ear) |
|||
let ear = Ear(ear: parsed.ear, peripheral: peripheral, owner: self) |
|||
ears[parsed.ear] = ear |
|||
peripheral.delegate = ear |
|||
central.connect(peripheral, options: nil) |
|||
emit(["type": "connecting", "ear": parsed.ear]) |
|||
if seen.count >= 2 { |
|||
central.stopScan() |
|||
scanTimer?.cancel() |
|||
scanTimer = nil |
|||
} |
|||
} |
|||
|
|||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { |
|||
peripheral.discoverServices([svc]) |
|||
} |
|||
|
|||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { |
|||
fail(code: 6, message: "耳机连接失败") |
|||
} |
|||
|
|||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { |
|||
guard let ear = ears.values.first(where: { $0.peripheral.identifier == peripheral.identifier }) else { return } |
|||
if ear.handshook, active { |
|||
emit(["type": "earLost", "ear": ear.ear]) |
|||
fail(code: 6, message: "耳机已断开") |
|||
} |
|||
} |
|||
|
|||
fileprivate func earReady(_ ear: Int) { |
|||
guard ears.values.filter({ $0.handshook }).count >= 2 else { return } |
|||
downlink.start(frameMs: frameMs, session: self) |
|||
startKeepalive() |
|||
let pending = result |
|||
result = nil |
|||
emit(["type": "ready", "frameMs": frameMs]) |
|||
DispatchQueue.main.async { |
|||
pending?(["ok": true, "classicKeep": self.classicKeep, "frameMs": self.frameMs]) |
|||
} |
|||
os_log("面对面双耳就绪 frameMs=%d classicKeep=%d", log: log, type: .default, frameMs, classicKeep ? 1 : 0) |
|||
} |
|||
|
|||
fileprivate func uplink(ear: Int, pcm: Data) { |
|||
let leg = ear == 1 ? "A" : "B" |
|||
NotificationCenter.default.post( |
|||
name: FaceToFaceBleSession.uplinkNote, |
|||
object: nil, |
|||
userInfo: ["leg": leg, "pcm": pcm] |
|||
) |
|||
} |
|||
|
|||
fileprivate func emitEvent(_ map: [String: Any]) { |
|||
emit(map) |
|||
} |
|||
|
|||
private func startKeepalive() { |
|||
keepalive?.cancel() |
|||
let timer = DispatchSource.makeTimerSource(queue: queue) |
|||
timer.schedule(deadline: .now() + 5, repeating: 5) |
|||
timer.setEventHandler { [weak self] in |
|||
guard let self = self else { return } |
|||
let seq = UInt8(Int.random(in: 0...255)) |
|||
let frame = Data([0xAA, 0x05, 0x01, seq]) |
|||
for ear in self.ears.values { ear.writeCtrl(frame) } |
|||
} |
|||
timer.resume() |
|||
keepalive = timer |
|||
} |
|||
|
|||
private func armAck() { |
|||
ackTimer?.cancel() |
|||
let timer = DispatchSource.makeTimerSource(queue: queue) |
|||
timer.schedule(deadline: .now() + 3) |
|||
timer.setEventHandler { [weak self] in self?.fail(code: 4, message: "耳机没有应答") } |
|||
timer.resume() |
|||
ackTimer = timer |
|||
} |
|||
|
|||
private func fail(code: Int, message: String) { |
|||
let pending = result |
|||
result = nil |
|||
shutdown(sendExit: true) |
|||
emit(["type": "error", "code": code, "message": message]) |
|||
if let pending = pending { |
|||
DispatchQueue.main.async { |
|||
pending(["ok": false, "code": code, "message": message]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private func shutdown(sendExit: Bool) { |
|||
scanTimer?.cancel() |
|||
scanTimer = nil |
|||
ackTimer?.cancel() |
|||
ackTimer = nil |
|||
keepalive?.cancel() |
|||
keepalive = nil |
|||
central?.stopScan() |
|||
downlink.stop() |
|||
let snapshot = ears |
|||
ears.removeAll() |
|||
seen.removeAll() |
|||
for ear in snapshot.values { |
|||
ear.handshook = false |
|||
if sendExit { ear.writeCtrl(Data([0xAA, 0x06, 0x00])) } |
|||
if let central = central { central.cancelPeripheralConnection(ear.peripheral) } |
|||
} |
|||
if sendExit { |
|||
if BluetoothManager.sharedInstance?.isConnected == true { |
|||
BluetoothManager.writeBytes(Data([0xAA, 0x6C])) |
|||
} else if !classicKeep { |
|||
UserDefaults.standard.set(true, forKey: pendingKey) |
|||
} |
|||
} |
|||
let was = active |
|||
active = false |
|||
if was { emit(["type": "exited"]) } |
|||
} |
|||
|
|||
private func emit(_ map: [String: Any]) { |
|||
let sink = eventSink |
|||
DispatchQueue.main.async { sink?(map) } |
|||
} |
|||
|
|||
private func finish(_ result: @escaping FlutterResult, ok: Bool, code: Int, message: String) { |
|||
DispatchQueue.main.async { |
|||
result(["ok": ok, "code": code, "message": message]) |
|||
} |
|||
} |
|||
|
|||
private func parseAdv(_ adv: [String: Any]) -> (ver: Int, ear: Int, token: Data)? { |
|||
let raw = adv[CBAdvertisementDataManufacturerDataKey] as? Data |
|||
guard let raw = raw, raw.count >= 11 else { return nil } |
|||
let body = raw.subdata(in: 2..<raw.count) |
|||
guard body.count >= 9, body[0] == 0xF2, body[1] == 0xF2 else { return nil } |
|||
return (Int(body[2]), Int(body[3]), body.subdata(in: 4..<8)) |
|||
} |
|||
|
|||
fileprivate func writeAudio(ear: Int, packet: Data) { |
|||
ears[ear]?.writeAudio(packet) |
|||
} |
|||
} |
|||
|
|||
private final class Ear: NSObject, CBPeripheralDelegate { |
|||
let ear: Int |
|||
let peripheral: CBPeripheral |
|||
weak var owner: FaceToFaceBleSession? |
|||
var handshook = false |
|||
private var ctrlTx: CBCharacteristic? |
|||
private var ctrlRx: CBCharacteristic? |
|||
private var audioUp: CBCharacteristic? |
|||
private var audioDown: CBCharacteristic? |
|||
private var notifyStep = 0 |
|||
private let dec = G722Codec(sampleRate: 16000) |
|||
private var seqExpected = -1 |
|||
private var seenOwn = false |
|||
|
|||
init(ear: Int, peripheral: CBPeripheral, owner: FaceToFaceBleSession) { |
|||
self.ear = ear |
|||
self.peripheral = peripheral |
|||
self.owner = owner |
|||
} |
|||
|
|||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { |
|||
guard let service = peripheral.services?.first else { return } |
|||
peripheral.discoverCharacteristics(nil, for: service) |
|||
} |
|||
|
|||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { |
|||
for ch in service.characteristics ?? [] { |
|||
switch ch.uuid { |
|||
case CBUUID(string: "AEAF5F32-0002-4C4B-A100-000000000001"): ctrlTx = ch |
|||
case CBUUID(string: "AEAF5F32-0003-4C4B-A100-000000000001"): ctrlRx = ch |
|||
case CBUUID(string: "AEAF5F32-0004-4C4B-A100-000000000001"): audioUp = ch |
|||
case CBUUID(string: "AEAF5F32-0005-4C4B-A100-000000000001"): audioDown = ch |
|||
default: break |
|||
} |
|||
} |
|||
enableNext(peripheral) |
|||
} |
|||
|
|||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { |
|||
enableNext(peripheral) |
|||
} |
|||
|
|||
private func enableNext(_ peripheral: CBPeripheral) { |
|||
let order = [ctrlRx, audioUp] |
|||
if notifyStep < order.count, let ch = order[notifyStep] { |
|||
notifyStep += 1 |
|||
peripheral.setNotifyValue(true, for: ch) |
|||
return |
|||
} |
|||
var hello = Data([0xAA, 0x01, 0x06, 0x02]) |
|||
hello.append(ownerToken()) |
|||
hello.append(0x01) |
|||
writeCtrl(hello) |
|||
} |
|||
|
|||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { |
|||
guard let value = characteristic.value, value.count >= 3, value[0] == 0xBB else { return } |
|||
let op = value[1] |
|||
let len = Int(value[2]) |
|||
guard value.count >= 3 + len else { return } |
|||
let payload = value.subdata(in: 3..<(3 + len)) |
|||
if op == 0x90 { onAudio(payload); return } |
|||
if op == 0x81 { |
|||
writeCtrl(Data([0xAA, 0x02, 0x03, 0x00, 80, 0x00])) |
|||
} else if op == 0x82 { |
|||
writeCtrl(Data([0xAA, 0x03, 0x00])) |
|||
} else if op == 0x83 { |
|||
handshook = true |
|||
owner?.earReady(ear) |
|||
} else if op == 0x87, !payload.isEmpty { |
|||
let kind = Int(payload[0]) |
|||
if kind == 0x01, payload.count >= 3 { |
|||
owner?.emitEvent([ |
|||
"type": "button", |
|||
"ear": ear, |
|||
"action": Int(payload[2]), |
|||
]) |
|||
} else if kind == 0x02, payload.count >= 2 { |
|||
owner?.emitEvent(["type": "wear", "ear": ear, "state": Int(payload[1])]) |
|||
} else if kind == 0x06 { |
|||
let reason = payload.count >= 2 ? Int(payload[1]) : 0 |
|||
owner?.emitEvent(["type": "exitRequest", "ear": ear, "reason": reason]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private func onAudio(_ p: Data) { |
|||
guard p.count >= 4 else { return } |
|||
let flags = Int(p[0]) |
|||
let seq = Int(p[1]) | (Int(p[2]) << 8) |
|||
if seqExpected >= 0 { |
|||
var gap = (seq - seqExpected) & 0xFFFF |
|||
if gap > 4 { gap = 4 } |
|||
if gap > 1, let silence = dec.decode(Data(repeating: 0xFF, count: 40 * (gap - 1))) { |
|||
owner?.uplink(ear: ear, pcm: silence) |
|||
} |
|||
} |
|||
seqExpected = (seq + max(1, (p.count - 3) / 40)) & 0xFFFF |
|||
let own = (flags & 0x80) != 0 |
|||
if own { seenOwn = true } |
|||
if seenOwn && !own { return } |
|||
var off = 3 |
|||
while off + 40 <= p.count { |
|||
let chunk = p.subdata(in: off..<(off + 40)) |
|||
if let pcm = dec.decode(chunk) { |
|||
owner?.uplink(ear: ear, pcm: pcm) |
|||
} |
|||
off += 40 |
|||
} |
|||
} |
|||
|
|||
func writeCtrl(_ data: Data) { |
|||
guard let ch = ctrlTx else { return } |
|||
peripheral.writeValue(data, for: ch, type: .withoutResponse) |
|||
} |
|||
|
|||
func writeAudio(_ data: Data) { |
|||
guard let ch = audioDown else { return } |
|||
peripheral.writeValue(data, for: ch, type: .withoutResponse) |
|||
} |
|||
|
|||
private func ownerToken() -> Data { |
|||
// HELLO 的 TOKEN 与 AA 6B 同一份。拿不到时发 4 个 0,握手会被耳机拒绝。 |
|||
return FaceToFaceBleSession.shared.currentToken() |
|||
} |
|||
} |
|||
|
|||
private final class Downlink { |
|||
private var legs: [Int: [Data]] = [1: [], 2: []] |
|||
private var seq: [Int: Int] = [1: 0, 2: 0] |
|||
private var primed = false |
|||
private var timer: DispatchSourceTimer? |
|||
private weak var session: FaceToFaceBleSession? |
|||
private var frameMs = 40 |
|||
private let enc = G722Codec(sampleRate: 16000) |
|||
private let lock = NSLock() |
|||
|
|||
func offer(ear: Int, pcm: Data) { |
|||
lock.lock() |
|||
let queued = (legs[ear]?.count ?? 0) * frameMs |
|||
if queued > 500 { |
|||
legs[ear]?.removeAll { $0.allSatisfy { $0 == 0xFF } } |
|||
} |
|||
if queued > 2500 { |
|||
// 积压太大时丢掉最旧的一包,避免越说越慢。Android 用变速,iOS 这里直接丢最旧。 |
|||
if (legs[ear]?.count ?? 0) > 8 { legs[ear]?.removeFirst() } |
|||
} |
|||
var off = 0 |
|||
while off + 640 <= pcm.count { |
|||
let slice = pcm.subdata(in: off..<(off + 640)) |
|||
if let g = enc.encode(slice), g.count == 40 { |
|||
legs[ear, default: []].append(g) |
|||
} |
|||
off += 640 |
|||
} |
|||
lock.unlock() |
|||
} |
|||
|
|||
func start(frameMs: Int, session: FaceToFaceBleSession) { |
|||
self.frameMs = frameMs |
|||
self.session = session |
|||
primed = false |
|||
timer?.cancel() |
|||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "com.eaimar.f2f.down")) |
|||
timer.schedule(deadline: .now() + .milliseconds(frameMs), repeating: .milliseconds(frameMs)) |
|||
timer.setEventHandler { [weak self] in self?.tick() } |
|||
timer.resume() |
|||
self.timer = timer |
|||
} |
|||
|
|||
func stop() { |
|||
timer?.cancel() |
|||
timer = nil |
|||
lock.lock() |
|||
legs = [1: [], 2: []] |
|||
lock.unlock() |
|||
primed = false |
|||
} |
|||
|
|||
private func tick() { |
|||
let frames = frameMs == 20 ? 1 : 2 |
|||
lock.lock() |
|||
var packets: [(Int, Data)] = [] |
|||
for ear in [1, 2] { |
|||
var body = Data() |
|||
var flags = 0 |
|||
for _ in 0..<frames { |
|||
if let next = legs[ear]?.first { |
|||
legs[ear]?.removeFirst() |
|||
body.append(next) |
|||
} else if primed { |
|||
body.append(Data(repeating: 0xFF, count: 40)) |
|||
} |
|||
} |
|||
if body.isEmpty { continue } |
|||
if !primed { flags |= 0x50 } |
|||
let s = seq[ear] ?? 0 |
|||
seq[ear] = (s + 1) & 0xFFFF |
|||
var pkt = Data([0xAA, 0x91, UInt8(3 + body.count), UInt8((ear & 0x0F) | flags)]) |
|||
pkt.append(UInt8(s & 0xFF)) |
|||
pkt.append(UInt8((s >> 8) & 0xFF)) |
|||
pkt.append(body) |
|||
packets.append((ear, pkt)) |
|||
} |
|||
lock.unlock() |
|||
if !packets.isEmpty { primed = true } |
|||
for (ear, pkt) in packets { |
|||
session?.writeAudio(ear: ear, packet: pkt) |
|||
} |
|||
} |
|||
} |
|||
|
|||
extension FaceToFaceBleSession { |
|||
fileprivate func currentToken() -> Data { token } |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
package com.recorder.holder |
|||
|
|||
import android.content.* |
|||
import android.os.Build |
|||
import android.util.Log |
|||
/** |
|||
* 系统启动接收器 |
|||
* 当系统重启完成后,自动启动BLE前台服务 |
|||
* 注意:需要用户在设置中授予应用自启动权限才能生效 |
|||
*/ |
|||
class BootReceiver : BroadcastReceiver() { |
|||
private val TAG = "BootReceiver" |
|||
override fun onReceive(ctx: Context, i: Intent) { |
|||
val serviceClass = try { |
|||
Class.forName("com.recorder.holder.BleMediaService") |
|||
} catch (e: ClassNotFoundException) { |
|||
Log.i(TAG, "BleMediaService不存在") |
|||
return |
|||
} |
|||
if (i.action == Intent.ACTION_BOOT_COMPLETED) { |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) |
|||
ctx.startForegroundService(Intent(ctx, serviceClass)) |
|||
else ctx.startService(Intent(ctx, serviceClass)) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,108 @@ |
|||
import 'package:eaimar/data/services/language_configs/azure_language_config.dart'; |
|||
import 'package:eaimar/data/services/language_manager.dart'; |
|||
import 'package:eaimar/data/services/microsoft_text_translation.dart'; |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
|
|||
/// 界面语言表补上微软三表里原先没开放的 40 种。 |
|||
/// 通话只要一边是这些语种,就整对走微软,原有语种的档位不动。 |
|||
void main() { |
|||
late LanguageManager languages; |
|||
|
|||
setUp(() { |
|||
languages = LanguageManager(); |
|||
}); |
|||
|
|||
test('语言表是原来的 39 种加上这 40 种', () { |
|||
expect(languages.getAllChineseNames(), hasLength(79)); |
|||
expect(LanguageManager.azureCallOnlyShortCodes, hasLength(40)); |
|||
for (final code in LanguageManager.azureCallOnlyShortCodes) { |
|||
expect(languages.getLanguageByShortCode(code), isNotNull, |
|||
reason: code); |
|||
} |
|||
}); |
|||
|
|||
test('粤语和香港繁体共用识别码,按名字仍是两条', () { |
|||
final yue = languages.getLanguageByChineseName('粤语'); |
|||
final hk = languages.getLanguageByChineseName('中文(香港繁体)'); |
|||
expect(yue?.shortCode, 'yue'); |
|||
expect(yue?.asrCode, 'zh-HK'); |
|||
expect(yue?.ttsVoiceName, 'yue-CN-XiaoMinNeural'); |
|||
expect(hk?.shortCode, 'zh-Hant-hk'); |
|||
expect(hk?.asrCode, 'zh-HK'); |
|||
expect(hk?.ttsVoiceName, 'zh-HK-HiuMaanNeural'); |
|||
// 按识别码反查保留先登记的香港繁体,系统语言默认值还指得上它。 |
|||
expect(languages.getChineseNameByAsrCode('zh-HK'), '中文(香港繁体)'); |
|||
expect(languages.getChineseNameByShortCode('yue'), '粤语'); |
|||
}); |
|||
|
|||
test('原有通话档位不变,扩展语种整对走微软', () { |
|||
expect(languages.findBestMatchingProvider('zh', 'en')?.provider, 'alibaba35'); |
|||
expect(languages.findBestMatchingProvider('zh', 'el')?.provider, 'alibaba'); |
|||
expect(languages.findBestMatchingProvider('zh', 'ro')?.provider, 'azure'); |
|||
|
|||
for (final code in ['fil', 'he', 'is', 'yue', 'cy', 'jv']) { |
|||
expect(languages.findBestMatchingProvider('zh', code)?.provider, 'azure', |
|||
reason: 'zh -> $code'); |
|||
expect(languages.findBestMatchingProvider(code, 'en')?.provider, 'azure', |
|||
reason: '$code -> en'); |
|||
} |
|||
}); |
|||
|
|||
test('爪哇语的微软翻译码是 jv', () { |
|||
final spec = getAzureTranslationLanguageSpecs() |
|||
.firstWhere((e) => e['shortCode'] == 'jv'); |
|||
expect(spec['translationCode'], 'jv'); |
|||
expect(languages.microsoftTranslateCode('yue'), 'yue'); |
|||
expect(languages.microsoftTranslateCode('zh'), 'zh-Hans'); |
|||
expect(languages.microsoftTranslateCode('zh-Hant-hk'), 'zh-Hant'); |
|||
expect(languages.microsoftTranslateCode('jv'), 'jv'); |
|||
}); |
|||
|
|||
test('同声面对面音视频的扩展语种走微软文本翻译,原有语种和通话不走', () { |
|||
for (final mode in ['simultaneous', 'faceToFace', 'audioVideo']) { |
|||
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'fil'), isTrue); |
|||
expect(LanguageManager.microsoftTextPipeline(mode, 'yue', 'en'), isTrue); |
|||
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'en'), isFalse); |
|||
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'el'), isFalse); |
|||
} |
|||
expect(LanguageManager.microsoftTextPipeline('call', 'zh', 'fil'), isFalse); |
|||
}); |
|||
|
|||
test('粤语播报用粤语音色,不串到香港繁体', () { |
|||
expect(languages.getTtsVoiceNameByShortCode('yue'), 'yue-CN-XiaoMinNeural'); |
|||
expect(languages.getTtsVoiceNameByShortCode('zh-Hant-hk'), |
|||
'zh-HK-HiuMaanNeural'); |
|||
}); |
|||
|
|||
test('繁体、乌克兰语、克罗地亚语走微软翻译码', () { |
|||
expect(microsoftMtRequired('en-US', 'zh-TW'), isTrue); |
|||
expect(microsoftMtRequired('zh-HK', 'en-US'), isTrue); |
|||
expect(microsoftMtRequired('zh-CN', 'uk-UA'), isTrue); |
|||
expect(microsoftMtRequired('hr-HR', 'en'), isTrue); |
|||
expect(microsoftMtRequired('zh-CN', 'en-US'), isFalse); |
|||
|
|||
expect(microsoftMtCode('zh-TW'), 'zh-Hant'); |
|||
expect(microsoftMtCode('zh-HK'), 'zh-Hant'); |
|||
expect(microsoftMtCode('zh-CN'), 'zh-Hans'); |
|||
expect(microsoftMtCode('uk-UA'), 'uk'); |
|||
expect(microsoftMtCode('hr-HR'), 'hr'); |
|||
expect(microsoftMtCode(''), ''); |
|||
}); |
|||
|
|||
test('解析微软翻译响应', () { |
|||
expect( |
|||
microsoftTranslationText([ |
|||
{ |
|||
'translations': [ |
|||
{'text': ' hello ', 'to': 'en'} |
|||
] |
|||
} |
|||
]), |
|||
'hello', |
|||
); |
|||
expect(microsoftTranslationText([]), isNull); |
|||
expect(microsoftTranslationText([ |
|||
{'translations': []} |
|||
]), isNull); |
|||
}); |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
|
|||
import 'package:eaimar/devices/bes/bes_f2f_protocol.dart'; |
|||
import 'package:eaimar/devices/bes/bes_protocol.dart'; |
|||
|
|||
void main() { |
|||
const token = [0x11, 0x22, 0x33, 0x44]; |
|||
|
|||
test('AA 6B / AA 6C 与探测区间', () { |
|||
expect(BesF2f.enterFrame(token), |
|||
[0xAA, 0x6B, 0x06, 0x02, 0x01, 0x11, 0x22, 0x33, 0x44]); |
|||
expect(BesF2f.exitFrame(), [0xAA, 0x6C]); |
|||
expect(BesCmd.switchesMode(0x6B), isTrue); |
|||
expect(BesCmd.switchesMode(0x6C), isTrue); |
|||
expect(BesCmd.switchesMode(0x6D), isFalse); |
|||
expect(BesCmd.frame(BesCmd.faceToFaceStart), [0xAA, 0x66]); |
|||
}); |
|||
|
|||
test('BB EB 双耳可用且经典链路保持', () { |
|||
final ack = BesF2f.parseEnterAck( |
|||
[0xBB, 0xEB, 0x07, 0x00, 0x03, 0x02, 0x01, 40, 0x03, 0x01]); |
|||
expect(ack, isNotNull); |
|||
expect(ack!.result, 0); |
|||
expect(ack.bothEars, isTrue); |
|||
expect(ack.frameMs, 40); |
|||
expect(ack.classicKeep, isTrue); |
|||
expect(ack.fwMinor, 3); |
|||
}); |
|||
|
|||
test('广播 token 对不上就不是这副耳机', () { |
|||
final ad = BesF2f.parseAdvertPayload( |
|||
[0xF2, 0xF2, 0x02, 0x01, 0x11, 0x22, 0x33, 0x44, 0x80 | 70]); |
|||
expect(ad, isNotNull); |
|||
expect(ad!.ear, BesF2f.earLeft); |
|||
expect(ad.battery, 70); |
|||
expect(ad.charging, isTrue); |
|||
expect(ad.tokenEquals(token), isTrue); |
|||
expect(ad.tokenEquals(const [0x11, 0x22, 0x33, 0x45]), isFalse); |
|||
expect( |
|||
BesF2f.parseAdvertPayload( |
|||
[0xF2, 0xF2, 0x02, 0x09, 0x11, 0x22, 0x33, 0x44, 0x10]), |
|||
isNull); |
|||
}); |
|||
|
|||
test('握手帧与下行音频耳位', () { |
|||
expect(BesF2f.hello(token), |
|||
[0xAA, 0x01, 0x06, 0x02, 0x11, 0x22, 0x33, 0x44, 0x01]); |
|||
expect(BesF2f.sessionCfg(), [0xAA, 0x02, 0x03, 0x00, 80, 0x00]); |
|||
expect(BesF2f.startStream(), [0xAA, 0x03, 0x00]); |
|||
expect(BesF2f.exitEar(), [0xAA, 0x06, 0x00]); |
|||
final down = BesF2f.audioDown( |
|||
ear: BesF2f.earRight, |
|||
flags: BesF2f.flagPlayBegin | BesF2f.flagFlush, |
|||
seq: 0x0102, |
|||
g722: const [0xAB, 0xCD], |
|||
); |
|||
expect(down.sublist(0, 6), [0xAA, 0x91, 0x05, 0x52, 0x02, 0x01]); |
|||
expect(down.sublist(6), [0xAB, 0xCD]); |
|||
}); |
|||
|
|||
test('上行音频 OWN_VOICE 与序号', () { |
|||
final frame = BesF2f.parseBle( |
|||
[0xBB, 0x90, 0x05, 0x91, 0x02, 0x01, 0x10, 0x20]); |
|||
final audio = BesF2f.parseAudioUp(frame!); |
|||
expect(audio!.ear, BesF2f.earLeft); |
|||
expect(audio.ownVoice, isTrue); |
|||
expect(audio.seq, 0x0102); |
|||
expect(audio.g722, [0x10, 0x20]); |
|||
}); |
|||
|
|||
test('来电退出事件', () { |
|||
final frame = BesF2f.parseBle([0xBB, 0x87, 0x02, 0x06, 0x03]); |
|||
expect(frame!.op, BesF2f.opEvent); |
|||
expect(frame.payload[0], BesF2f.evtExitRequest); |
|||
expect(frame.payload[1], BesF2f.exitIncomingCall); |
|||
expect(BesF2f.messageKey(BesF2f.errBusy), 'f2fEndOtherFeature'); |
|||
expect(BesF2f.isExitAckOk([0xBB, 0xEC, 0x01, 0x00]), isTrue); |
|||
}); |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue