Browse Source
iOS 不允许一个 App 读另一个 App 的音频,Broadcast Upload Extension 是系统
留的唯一口子。链路:
其他 App 播放 → 系统录屏广播 → BroadcastExtension(独立进程)
收 RPSampleBufferType.audioApp → 转 16k/mono/Int16
→ App Group 容器下的 Unix domain socket
→ 主 App BroadcastAudioReceiver
→ azureAsrHelper.audioStream.saveAudioDataTo() ← 已有的 external 通路
→ ASR → 翻译 → 弹幕
新增:
- ios/BroadcastExtension/{SampleHandler,BroadcastAudioSocket}.swift、Info.plist、
entitlements、Extension.xcconfig
- azure_speech 插件的 BroadcastAudioReceiver.swift + 三个 method channel
(startBroadcastAudioReceiver / stopBroadcastAudioReceiver / presentBroadcastPicker)
- Runner.entitlements 加 App Group;xcodeproj 加 BroadcastExtension target
并在 Runner 里内嵌
对上层复用已有的 requestScreenCapture/stopScreenCapture 接缝(Android 是
MediaProjection,iOS 是广播扩展),控制器里两端走同一条分支。
几处踩过的坑,都写进了注释:
- SOCK_STREAM 不保留消息边界,帧必须带 4 字节长度前缀,否则喂给 ASR 的是
错位样本。
- CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer 不拷贝数据,只把
mData 指向 blockBuffer 的内存;blockBuffer 出作用域即释放,交出去就是野指针。
改成在 withExtendedLifetime 里 memcpy 出来。
- AudioBufferList 的 buffer 个数要按 interleaved 与否算,写死 1 会让非交错
立体声整帧拿不到。
- sun_path 只有 104 字节,socket 只能放在 App Group 容器根下,不能建子目录。
- 扩展侧 connect 失败要限流重试(1s),每帧重连会把扩展 CPU 打满,而它的
内存上限只有 50MB。
- SO_NOSIGPIPE:主 App 挂掉时写 socket 默认会直接杀死扩展进程。
- media 这个 ASR 模式的会话配置必须排在 audioSourceType 判断之前,且要
.mixWithOthers —— 否则我们一 setActive 就把正在播放的那个 App 掐了。
- Embed App Extensions 阶段必须插在 Thin Binary 之前。追加到最后会和
Flutter/CocoaPods 的脚本阶段构成依赖环,Xcode 报 Cycle inside Runner。
⚠️ 尚未打通签名:需要在苹果开发者后台登记 App Group group.com.yimai.eaimar、
给 com.yimai.eaimar 开 App Groups capability、并为
com.yimai.eaimar.BroadcastExtension 建 App ID。Xcode 当前还登录不上账号
(-1200)。在这之前 iOS 包签不出来。代码已用 --no-codesign 验证可编译、
appex 也正确内嵌进 PlugIns/。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
13 changed files with 1308 additions and 38 deletions
@ -0,0 +1,491 @@ |
|||
# 面对面翻译 · 独立 BLE 链路协议(草案 v0.1) |
|||
|
|||
> 状态:**待固件确认**。本文只定义协议,不含实现。 |
|||
> 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。 |
|||
> |
|||
> 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有 SPP 协议,本文与它并存,不替代) |
|||
|
|||
--- |
|||
|
|||
## 0. 为什么要单开一条 BLE |
|||
|
|||
面对面翻译要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同。 |
|||
|
|||
现有恒玄链路是**一条经典蓝牙 SPP**(RFCOMM,UUID `aeaf5241-5453-7070-7353-445542594c46`), |
|||
所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。 |
|||
这套结构服务「通话翻译」够用(两路的语义是固定的:本端 mic / 对端 spk), |
|||
但面对面的两路是**两个人**,需要: |
|||
|
|||
- 两路上行**同时并发**且可独立起停(A 说话时 B 插话,不能排队); |
|||
- 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵); |
|||
- 每路带**独立序号**,好判丢包、好做打断。 |
|||
|
|||
在 SPP 的定长双槽结构上硬塞这些语义,会把现有通话翻译的包格式一起改掉。 |
|||
所以本功能**另起一条 BLE 链路**,与 SPP 完全隔离:进面对面时建链,退出时拆链并恢复原状。 |
|||
|
|||
--- |
|||
|
|||
## 1. 设计边界(明确不动什么) |
|||
|
|||
| 不动 | 说明 | |
|||
|---|---| |
|||
| 现有 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 66` / `AA 67` 面对面 | 那是**旧版**面对面(手机麦上行 + `AA 69` 单声道下行),保留可用,新模式用新命令号,两者互斥但互不修改 | |
|||
| 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们**互斥**,不并行 | |
|||
| 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(见 §11) | |
|||
|
|||
App 侧对应载体:[face_to_face_ble_service.dart](../lib/data/services/face_to_face_ble_service.dart) |
|||
(接口已固化,本文补的是它 `TODO(固件)` 里缺的那部分)。 |
|||
业务层 [translation_controller.dart](../lib/modules/translation/controllers/translation_controller.dart) |
|||
的 `FaceToFaceTrigger` / `activeSpeaker` 语义不变。 |
|||
|
|||
--- |
|||
|
|||
## 2. 总体拓扑:两档,协议同一套 |
|||
|
|||
固件按自身能力选一档,**App 侧接口与业务逻辑完全相同**,靠握手里的 `TOPO` 字段自适应。 |
|||
|
|||
### 档位 A —— 单链路(主耳中转)· 建议先落地 |
|||
|
|||
``` |
|||
BLE (1 条) |
|||
手机 ────────────────► 主耳 ──耳间私有链路──► 副耳 |
|||
控制/双路音频 按 EAR 字段分发 |
|||
``` |
|||
|
|||
- 只有主耳做 GATT peripheral,两路音频在同一条 BLE 上用帧头的 `EAR` 字段区分; |
|||
- 与现有 SPP 的双路交织同构,固件改动量最小,TWS 主从关系不用拆; |
|||
- 代价:副耳多一跳转发(实测应 < 20ms,需固件给实测值);耳间链路带宽要能承载一路上行 + 一路下行。 |
|||
|
|||
### 档位 B —— 双链路(左右耳各自独立)· 目标形态 |
|||
|
|||
``` |
|||
BLE (2 条,互相独立) |
|||
手机 ═══════════════► 左耳 |
|||
═══════════════► 右耳 |
|||
``` |
|||
|
|||
- 左右耳各自广播、各自被 App 连接,各跑一条 GATT; |
|||
- 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R); |
|||
- 延迟最低、两路彻底解耦;代价是要拆 TWS 主从,A2DP/HFP 期间必然中断(见 §14 Q1/Q2)。 |
|||
|
|||
> **协议帧在两档下完全一致**。档位 B 时每条链路只应出现自己耳位的 `EAR` 值, |
|||
> 收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`。 |
|||
|
|||
--- |
|||
|
|||
## 3. 完整时序 |
|||
|
|||
``` |
|||
App 耳机(主耳) 副耳 |
|||
│ │ │ |
|||
│ ① SPP: AA 6B <ver><cap><topo><token> │ │ |
|||
│───────────────────────────────────────►│ │ |
|||
│ │ 切工作模式:停A2DP/HFP│ |
|||
│ ② SPP: BB EB <ok><topo><earmask>... │ 起 BLE 广播(带 token) │ |
|||
│◄───────────────────────────────────────│ │ |
|||
│ │ │ |
|||
│ ③ BLE 扫描 → 按 token 过滤 → 连接 │ │ |
|||
│ ④ MTU 交换 / 2M PHY / 订阅通知 │ │ |
|||
│───────────────────────────────────────►│ │ |
|||
│ │ │ |
|||
│ ⑤ BLE: AA 01 HELLO <token><role> │ │ |
|||
│───────────────────────────────────────►│ │ |
|||
│ ⑥ 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 周期) │ │ |
|||
│◄──────────────────────────────────────►│ │ |
|||
│ │ │ |
|||
│ ⑬ BLE: AA 04 STOP_STREAM │ │ |
|||
│───────────────────────────────────────►│ │ |
|||
│ ⑭ BLE 主动断开 │ │ |
|||
│ ⑮ SPP: AA 6C 退出面对面 │ │ |
|||
│───────────────────────────────────────►│ 停广播/恢复TWS/恢复A2DP│ |
|||
│ ⑯ SPP: BB EC <ok> │ │ |
|||
│◄───────────────────────────────────────│ │ |
|||
``` |
|||
|
|||
**为什么入口放在 SPP 而不是直接扫 BLE**:耳机平时不广播这个服务(省电、也避免被无关 App 扫到), |
|||
只有收到 `AA 6B` 才开始广播,且广播里带上 App 给的 token。 |
|||
这样「扫描时连到隔壁桌同型号耳机」这个问题在协议层就不成立(见 §5)。 |
|||
|
|||
--- |
|||
|
|||
## 4. 阶段一 · SPP 侧模式切换 |
|||
|
|||
沿用现有 SPP 帧格式:`AA CMD [LEN] [payload]` / `BB CMD LEN [payload]`。 |
|||
命令号选在模式控制段(`0x5x`/`0x6x`)内的空位,响应码遵守本机固件 `响应 = 请求 | 0x80` 的规律。 |
|||
|
|||
> 已占用下行:`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` |
|||
> 本协议新增 `6B/6C` ↔ `EB/EC`,与上表均无冲突。 |
|||
> |
|||
> ⚠️ App 侧的 `BesBluetoothService.probeCommands()` 目前跳过的是 `0x51~0x6A`(会切工作模式的段), |
|||
> **落地本协议时必须把跳过区间扩到 `0x6C`** —— 否则扫命令时会把耳机扫进面对面模式, |
|||
> 且扫描进程不会发 `AA 6C` 退出,只能靠看门狗兜底。 |
|||
|
|||
### 4.1 `AA 6B` 进入面对面 BLE 模式 |
|||
|
|||
``` |
|||
AA 6B 07 <PROTO_VER> <CAP> <TOPO_REQ> <TOKEN 4B> |
|||
``` |
|||
|
|||
| 字段 | 长度 | 取值 | |
|||
|---|---|---| |
|||
| `PROTO_VER` | 1 | 本文档版本,首版 `0x01` | |
|||
| `CAP` | 1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 `0x01` | |
|||
| `TOPO_REQ` | 1 | `0x00`=由耳机决定(推荐)、`0x01`=要求单链路、`0x02`=要求双链路 | |
|||
| `TOKEN` | 4 | App 生成的随机会话标识,每次进入重新生成,用于广播过滤 | |
|||
|
|||
### 4.2 `BB EB` 响应 |
|||
|
|||
``` |
|||
BB EB 08 <RESULT> <TOPO> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> |
|||
``` |
|||
|
|||
| 字段 | 取值 | |
|||
|---|---| |
|||
| `RESULT` | 见 §12 错误码,`0x00`=已进入并开始广播 | |
|||
| `TOPO` | `0x01`=单链路(仅主耳广播)、`0x02`=双链路(左右耳各自广播) | |
|||
| `EAR_MASK` | bit0=左耳可用、bit1=右耳可用。**单耳在盒时只置一位**,App 据此提示用户 | |
|||
| `CODEC` | 实际采用的编码,`0x01`=G.722 | |
|||
| `FRAME_MS` | 每包承载的音频时长,`20` 或 `40`(建议 40) | |
|||
| `FW_MINOR` | 固件的本协议实现小版本,便于后续差异化兼容 | |
|||
|
|||
### 4.3 `AA 6C` 退出 |
|||
|
|||
``` |
|||
AA 6C → BB EC 01 <RESULT> |
|||
``` |
|||
|
|||
⚠️ **必须幂等**:不在面对面模式时收到 `AA 6C` 也要回 `BB EC 00`,不能报错。 |
|||
App 在异常恢复路径上会无条件补发一次(见 §11)。 |
|||
|
|||
--- |
|||
|
|||
## 5. 阶段二 · BLE 广播与发现 |
|||
|
|||
### 5.1 广播内容 |
|||
|
|||
| 字段 | 内容 | |
|||
|---|---| |
|||
| Flags | LE General Discoverable + BR/EDR Not Supported | |
|||
| Local Name | `<产品名>-F2F`,左右耳**可以同名**(靠 MSD 区分,不靠名字) | |
|||
| Service UUID (16B) | 见 §6.1 | |
|||
| Manufacturer Specific Data | 见下 | |
|||
|
|||
**Manufacturer Specific Data(AD Type `0xFF`,共 11 字节)** |
|||
|
|||
``` |
|||
| CompanyID 2B (LE) | MAGIC 0xF2 0xF2 | VER 1B | EAR 1B | TOKEN 4B | BATT 1B | |
|||
``` |
|||
|
|||
| 字段 | 说明 | |
|||
|---|---| |
|||
| `MAGIC` | 固定 `F2 F2`,快速筛掉无关广播 | |
|||
| `VER` | 协议版本,与 `AA 6B` 的 `PROTO_VER` 一致 | |
|||
| `EAR` | `0x01`=左耳、`0x02`=右耳、`0x03`=主耳(单链路档,代表双耳) | |
|||
| `TOKEN` | **原样回显 `AA 6B` 里那 4 字节** | |
|||
| `BATT` | 该耳电量 0~100,低 7 位;最高位为充电位(与 `BB 0A` 同口径) | |
|||
|
|||
### 5.2 App 侧扫描规则(强制) |
|||
|
|||
1. 只接受 `MAGIC` 命中的广播; |
|||
2. **`TOKEN` 必须与本次 `AA 6B` 下发的完全一致,否则一律忽略** —— |
|||
这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副; |
|||
3. 档位 B 下需集齐 `EAR=0x01` 与 `0x02` 两个广播才算就绪;只集到一个时按 §14 Q9 的降级策略处理; |
|||
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」。 |
|||
|
|||
### 5.3 广播参数与耳机侧超时 |
|||
|
|||
- 建链期快速广播:间隔 30~50ms,持续 30s; |
|||
- **30s 内无人连接,耳机自动退出面对面模式并恢复常态**(第一道看门狗,见 §11); |
|||
- 连接建立后立即停止广播(档位 B 下各耳独立停)。 |
|||
|
|||
### 5.4 iOS 注意 |
|||
|
|||
iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同的 peripheral UUID), |
|||
所以**设备身份判定只能靠 TOKEN + EAR,不能靠地址**。这也是 §5.2 第 2 条必须强制的原因。 |
|||
(同一问题在设备确权上已经踩过,见 CLAUDE.md「设备确权」一节。) |
|||
|
|||
--- |
|||
|
|||
## 6. 阶段三 · GATT 服务与特征 |
|||
|
|||
### 6.1 UUID(**待固件分配,下表为占位**) |
|||
|
|||
沿用现有 SPP UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000` |
|||
|
|||
| 项 | 占位 UUID | 属性 | 方向 | |
|||
|---|---|---|---| |
|||
| Service | `AEAF5F32-0001-4C4B-A100-000000000001` | — | — | |
|||
| `CTRL_TX` | `AEAF5F32-0002-4C4B-A100-000000000001` | Write / Write Without Response | App → 耳机(控制) | |
|||
| `CTRL_RX` | `AEAF5F32-0003-4C4B-A100-000000000001` | Notify | 耳机 → App(控制/事件) | |
|||
| `AUDIO_UP` | `AEAF5F32-0004-4C4B-A100-000000000001` | Notify | 耳机 → App(音频) | |
|||
| `AUDIO_DOWN` | `AEAF5F32-0005-4C4B-A100-000000000001` | Write Without Response | App → 耳机(音频) | |
|||
|
|||
**控制与音频分开成 4 个特征,不合并**:音频是每 40ms 一包的高频流, |
|||
与控制帧共用一个特征时,控制消息(尤其是「打断」和 `STOP_STREAM`)会排在音频队列后面, |
|||
表现为「点了停止还在播」。分开后两条队列各自独立。 |
|||
|
|||
### 6.2 链路参数要求 |
|||
|
|||
| 项 | 要求 | 理由 | |
|||
|---|---|---| |
|||
| ATT MTU | ≥ 185,建议 247 | 40ms 聚合包 = 6B 头 + 80B 音频 = 86B;留余量给未来的 Opus/更长聚合 | |
|||
| PHY | 优先 2M,回退 1M | 四路并发时降低空口占用 | |
|||
| 连接间隔 | 15~30ms | 大于 40ms 会让下行音频出现可闻的断续 | |
|||
| Slave latency | 0 | 音频流不允许休眠跳包 | |
|||
| 数据长度扩展 (DLE) | 开 | 否则 86B 包会被拆成多个 27B 空口包,吞吐骤降 | |
|||
|
|||
--- |
|||
|
|||
## 7. 阶段四 · BLE 链路握手 |
|||
|
|||
BLE 链路上的操作码空间**与 SPP 独立**(不同链路,不会混淆)。 |
|||
帧格式同样是 `AA <OP> <LEN> <payload>` / `BB <OP|0x80> <LEN> <payload>`,`LEN` 为 payload 字节数。 |
|||
|
|||
| 步骤 | 帧 | payload | 说明 | |
|||
|---|---|---|---| |
|||
| ① 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 单帧包 | |
|||
| ③ SESSION_CFG | `AA 02` | `<UP_ENABLE 1B><VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B>` | 见下 | |
|||
| ④ CFG_ACK | `BB 82` | `<RESULT>` | | |
|||
| ⑤ START_STREAM | `AA 03` | `<EAR_MASK 1B>` | 指定开哪几路上行;档位 B 下固定为本耳 | |
|||
| ⑥ START_ACK | `BB 83` | `<RESULT>` | 回 `0x00` 后耳机开始推 `BB 90` | |
|||
| ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) | |
|||
| ⑧ KEEPALIVE | `AA 05` | `<SEQ 1B>` | App 每 **1s** 发一次 | |
|||
| ⑨ KEEPALIVE_ACK | `BB 85` | `<SEQ 1B><LOSS 1B>` | `LOSS`=耳机侧统计的下行丢包率(%),用于诊断 | |
|||
|
|||
**`SESSION_CFG` 字段** |
|||
|
|||
| 字段 | 取值 | |
|||
|---|---| |
|||
| `UP_ENABLE` | bit0=左耳上行、bit1=右耳上行 | |
|||
| `VAD_MODE` | `0x00`=**常发**(不做静音抑制,默认)、`0x01`=静音抑制(耳机侧 VAD 判静音则不发包) | |
|||
| `DOWN_GAIN` | 下行播放增益 0~100,默认 80 | |
|||
| `TRIGGER` | `0x00`=免按键(连续拾音)、`0x01`=按住说话(按键事件驱动,对应 App 的 `FaceToFaceTrigger.device`) | |
|||
|
|||
> ⚠️ `VAD_MODE` 默认 `0x00` 是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎, |
|||
> 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑 |
|||
> (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开 `0x01`。 |
|||
|
|||
--- |
|||
|
|||
## 8. 音频上行(耳机 → App) |
|||
|
|||
``` |
|||
BB 90 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…> |
|||
``` |
|||
|
|||
| 字段 | 说明 | |
|||
|---|---| |
|||
| `EAR` | 低 4 位:`0x1`=左耳、`0x2`=右耳 | |
|||
| `FLAGS` | 高 4 位:bit4 `VAD_ACTIVE`(本包含人声)、bit5 `SEG_BEGIN`(一段发声起始)、bit6 `SEG_END`(结束)、bit7 保留 | |
|||
| `SEQ` | **每耳独立**自增,回绕 `0xFFFF`。App 据此算丢包,不做重传 | |
|||
| `PAYLOAD` | `N × 40B` G.722 码流。`FRAME_MS=40` 时 N=2,`=20` 时 N=1 | |
|||
|
|||
- 编码:**G.722**,16kHz / 16bit / 单声道,一帧 640B PCM(320 样本 = 20ms)→ 40B 码流。 |
|||
选它是因为现有原生插件的 `G722Codec` 编解码器两端都已落地、已验证,不引入新依赖。 |
|||
- `SEG_BEGIN` / `SEG_END` 是**耳机给的提示**,不是命令:App 可以用它抢跑一次断句, |
|||
但最终断句仍以云端 ASR 为准(`VAD_MODE=0x00` 时耳机照发不误)。 |
|||
- 丢包**不重传**:语音流重传只会加大延迟。App 检测到 seq 跳变时补静音帧维持时基。 |
|||
|
|||
--- |
|||
|
|||
## 9. 音频下行(App → 耳机) |
|||
|
|||
``` |
|||
AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…> |
|||
``` |
|||
|
|||
| 字段 | 说明 | |
|||
|---|---| |
|||
| `EAR` | 低 4 位:这包该播给哪只耳。档位 A 由主耳按此转发;档位 B 下与本链路耳位不符则丢弃并回 `ERR_EAR_MISMATCH` | |
|||
| `FLAGS` | bit4 `PLAY_BEGIN`(一段译文开始,耳机可据此压低本地音/给提示音)、bit5 `PLAY_END`(结束,可恢复)、bit6 `FLUSH`(**打断**:丢弃本耳未播完的缓冲,立即播本包) | |
|||
| `SEQ` | 每耳独立自增 | |
|||
| `PAYLOAD` | 同上行 | |
|||
|
|||
- **打断(`FLUSH`)是面对面必需的**:对方还在听上一句译文时新一句已经翻好, |
|||
没有 flush 就会越积越多、译文与对话彻底脱节。App 在每段译文首包置 `PLAY_BEGIN`, |
|||
需要抢播时同时置 `FLUSH`。 |
|||
- **不需要静音填充**。这点与 SPP 的 `AA 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧; |
|||
BLE 是包交换,没数据就不发包,耳机侧靠 `SEQ` 与到达时间维持时基即可。 |
|||
- **节流**:App 按 `FRAME_MS` 的实时速率发送。耳机可用 `BB 86 <interval_ms>` 请求改节奏 |
|||
(语义对齐现有 SPP 的 `0xD6/0xE9` 调速),合法值 `16 / 20 / 25 / 40`。 |
|||
- 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。 |
|||
|
|||
--- |
|||
|
|||
## 10. 事件上报 |
|||
|
|||
``` |
|||
BB 87 <LEN> <EVENT 1B> <PARAM…> |
|||
``` |
|||
|
|||
| `EVENT` | 含义 | PARAM | App 侧对应 | |
|||
|---|---|---|---| |
|||
| `0x01` | 按键 | `<EAR><KEY_CODE><ACTION>`,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller` 落地,固件只需如实上报 | |
|||
| `0x02` | 佩戴状态 | `<EAR><STATE>`,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 | |
|||
| `0x03` | 电量 | `<EAR><LEVEL>`,最高位为充电位 | 与 `BB 0A` 同口径 | |
|||
| `0x04` | 链路统计 | `<EAR><UP_LOSS%><DOWN_LOSS%>` | 每 10s 一次,仅用于诊断日志 | |
|||
| `0x05` | 异常 | `<CODE>`,见 §12 | 缓冲溢出、编码失败等 | |
|||
| `0x06` | 耳机请求退出 | `<REASON>` | 用户在耳机上操作或电量过低;App 收到后走正常退出流程 | |
|||
|
|||
--- |
|||
|
|||
## 11. 退出与恢复(重点) |
|||
|
|||
功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。 |
|||
|
|||
### 11.1 三层看门狗 |
|||
|
|||
| 层 | 条件 | 动作 | 责任方 | |
|||
|---|---|---|---| |
|||
| 1 | 开始广播后 **30s** 无 BLE 连接 | 停广播、退出面对面模式、恢复常态、SPP 主动上报 `BB EC 00` | 固件 | |
|||
| 2 | BLE 已连接但 **5s** 未收到 `AA 05` KEEPALIVE | 断 BLE、退出面对面模式、恢复常态 | 固件 | |
|||
| 3 | BLE 意外断开 / App 侧异常 | 立即补发 SPP `AA 6C`;SPP 也不可用时,**下次 SPP 连上后先发一次 `AA 6C`** 清状态 | App | |
|||
|
|||
> 第 2 层是本协议里最重要的一条。App 被系统杀掉、崩溃、用户强退时, |
|||
> 没有任何机会发退出指令;缺了它耳机会永远卡在面对面模式,用户表现为 |
|||
> 「耳机连着但放不了音乐、也接不了电话」,且看不出原因。 |
|||
> (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。) |
|||
|
|||
### 11.2 固件的恢复清单 |
|||
|
|||
收到 `AA 6C`、或任一看门狗触发时,**逐项恢复**: |
|||
|
|||
1. 停止 BLE 广播,断开本协议的 GATT 连接; |
|||
2. 恢复 TWS 主从与耳间同步(档位 B 拆过主从时); |
|||
3. 恢复 A2DP / HFP —— 音乐能播、来电能接; |
|||
4. 恢复按键功能映射到用户原设置(面对面期间若临时改过键位); |
|||
5. 恢复 ANC / 通透模式到进入前的档位; |
|||
6. 恢复麦克风通路与增益; |
|||
7. SPP 回 `BB EC 00`(看门狗触发时为主动上报)。 |
|||
|
|||
### 11.3 App 的恢复动作 |
|||
|
|||
- 退出 BLE 模式后不重连 SPP:SPP 链路全程未断,`BesBluetoothService` 状态不受影响; |
|||
- 恢复完成前不允许再次进入面对面(防止半状态叠加); |
|||
- UI 上「结束」按钮必须**先返回、后台跑收尾**,不要 await 整条退出链 |
|||
(录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。 |
|||
|
|||
### 11.4 互斥 |
|||
|
|||
面对面 BLE 模式与下列功能**同一时刻只能有一个**,冲突时 `AA 6B` 回 `ERR_BUSY`: |
|||
通话翻译(`AA 51`)、AI 对话(`0x64`)、主麦录音(`AA 59`)、媒体录音(`AA 5E`)、OTA、旧版面对面(`AA 66`)。 |
|||
|
|||
--- |
|||
|
|||
## 12. 错误码(`RESULT` / `CODE` 共用一张表) |
|||
|
|||
| 值 | 名称 | 含义 | App 侧表现 | |
|||
|---|---|---|---| |
|||
| `0x00` | `OK` | 成功 | — | |
|||
| `0x01` | `ERR_UNSUPPORTED` | 固件不支持本协议或版本过低 | 提示「耳机固件需升级」,引导 OTA | |
|||
| `0x02` | `ERR_BUSY` | 正在通话/AI/录音/OTA | 提示「请先结束当前功能」 | |
|||
| `0x03` | `ERR_EAR_UNAVAILABLE` | 单耳或双耳不可用(在盒/未佩戴) | 提示「请取出并佩戴双耳」 | |
|||
| `0x04` | `ERR_TOKEN_MISMATCH` | token 校验失败 | 静默重试一次,仍失败则回滚 | |
|||
| `0x05` | `ERR_BLE_RESOURCE` | BLE 资源不足(连接数/内存) | 提示重试 | |
|||
| `0x06` | `ERR_CODEC` | 编码不支持或初始化失败 | 记日志,回滚 | |
|||
| `0x07` | `ERR_TIMEOUT` | 阶段超时 | 回滚并提示 | |
|||
| `0x08` | `ERR_EAR_MISMATCH` | 下行包耳位与链路不符 | 记日志(属 App 侧 bug) | |
|||
| `0x09` | `EVT_BUF_OVERFLOW` | 下行缓冲溢出 | 记日志,App 降速 | |
|||
| `0x0A` | `ERR_INTERNAL` | 固件内部错误 | 回滚并提示 | |
|||
|
|||
--- |
|||
|
|||
## 13. 带宽与延迟预算 |
|||
|
|||
**带宽**(G.722 @16kHz = 16 kbps/路) |
|||
|
|||
| 流 | 路数 | 小计 | |
|||
|---|---|---| |
|||
| 上行(左耳 + 右耳) | 2 | 32 kbps | |
|||
| 下行(左耳 + 右耳) | 2 | 32 kbps | |
|||
| 控制 + keepalive | — | < 2 kbps | |
|||
| **合计** | | **≈ 66 kbps** | |
|||
|
|||
40ms 聚合时每包 86 字节,四条流合计每 40ms 4 包 ≈ 8.6 KB/s。 |
|||
BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**; |
|||
开 2M PHY + DLE 后更宽松。档位 B 下两条链路各承担一半,压力更小。 |
|||
|
|||
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps,四路 1 Mbps —— BLE 上不可行。 |
|||
> **所以下行/上行都必须是编码后码流,不能传裸 PCM。** |
|||
|
|||
**延迟预算**(单向,从 A 开口到 B 听到) |
|||
|
|||
| 段 | 预估 | 备注 | |
|||
|---|---|---| |
|||
| 耳机采集 + 编码 | 20~40ms | 一个聚合包 | |
|||
| BLE 上行 | 30~60ms | 含连接间隔与重传 | |
|||
| 档位 A 的耳间转发 | +10~20ms | 仅档位 A、且仅下行侧 | |
|||
| 云端 ASR→翻译→TTS | **800~2500ms** | 大头。端到端模型可压到 ~800ms,三段串行则 2s+ | |
|||
| BLE 下行 | 30~60ms | | |
|||
| 解码 + 抖动缓冲 + 播放 | 60~120ms | | |
|||
| **合计** | **≈ 1.0~2.8s** | | |
|||
|
|||
> 蓝牙链路只占其中 100~250ms。**优化重心在云端那一段**,不要在协议上过度抠 10ms。 |
|||
|
|||
--- |
|||
|
|||
## 14. 待固件确认清单 |
|||
|
|||
请逐条回复「支持 / 不支持 / 需改为」: |
|||
|
|||
| # | 问题 | 影响 | |
|||
|---|---|---| |
|||
| Q1 | 能否做**档位 B(双链路)**?拆开 TWS 主从后,两只耳机还能各自独立跑 GATT 并保持同步吗? | 决定最终形态;不能则先做档位 A | |
|||
| Q2 | 面对面模式下 **A2DP / HFP 是否必须中断**?若中断,来电时如何处理(自动退出面对面?) | 决定 §11 恢复清单与用户提示文案 | |
|||
| Q3 | §6.1 的 **GATT UUID 由谁分配**?固件方是否已有预留的 128-bit 基址? | 阻塞两端编码 | |
|||
| Q4 | 支持 **MTU 247 / 2M PHY / DLE** 吗?最大支持的 MTU 是多少? | 决定聚合帧长(40ms vs 20ms) | |
|||
| Q5 | **G.722 能否直接用在 BLE 通道**上(现有实现在 SPP 上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 | |
|||
| Q6 | 是否有**入耳检测 / 骨传导 / VPU** 可用来判定「是佩戴者本人在说话」?能否随上行包给出该标志? | 面对面串音抑制的关键;没有的话只能靠云端,效果差一档 | |
|||
| Q7 | 面对面模式下**哪些按键可用**、键位码如何编排?长按/抬起事件能否上报? | 决定「按住说话」能否做 | |
|||
| Q8 | 广播里的 **TOKEN 回显**能实现吗?广播内容能否在运行时动态改? | 不能则无法防止连错耳机,需另想办法 | |
|||
| Q9 | **单耳在盒 / 只戴一只**时的降级行为?是拒绝进入,还是以单耳模式进入? | 决定 `EAR_MASK` 语义与 App 提示 | |
|||
| Q10 | §11.1 的三层**看门狗超时值**(30s / 5s)是否合适?固件侧能否实现第 1、2 层? | 直接决定「App 崩了耳机会不会卡死」 | |
|||
| Q11 | 与 **OTA / AI 对话 / 通话**的互斥由固件拦(回 `ERR_BUSY`)还是 App 自律? | 建议固件拦,App 也拦,双保险 | |
|||
| Q12 | 档位 B 下两只耳机的**上行时间戳是否需要对齐**?耳间有共同时钟吗? | 影响能否做跨耳的回声/串音抑制 | |
|||
| Q13 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 | |
|||
| Q14 | 耳机侧**下行抖动缓冲**多大?溢出策略是丢旧还是丢新? | 影响打断(FLUSH)的实际效果 | |
|||
|
|||
--- |
|||
|
|||
## 15. 联调验收用例 |
|||
|
|||
| # | 场景 | 期望 | |
|||
|---|---|---| |
|||
| T01 | 正常进入 → 双耳广播 → 连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 | |
|||
| T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 | |
|||
| T03 | 双方同时说话 | 两路各自独立上行,互不排队,两路译文分别到达对侧 | |
|||
| T04 | 打断:A 的第二句在第一句译文播完前到达 | 置 `FLUSH` 后立即切到新句,不排队堆积 | |
|||
| T05 | 扫描期旁边有另一副同型号耳机(也在面对面模式) | **绝不连错**:token 不匹配的广播被忽略 | |
|||
| T06 | 正常退出 | 音乐可播、来电可接、按键功能恢复、ANC 恢复 | |
|||
| T07 | **App 进程被杀** | ≤ 5s 后耳机自行退出并恢复常态(看门狗 2) | |
|||
| T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出(看门狗 1) | |
|||
| T09 | 面对面期间来电 | 按 Q2 的结论执行,且结束后状态一致 | |
|||
| T10 | 面对面期间摘下一只耳 / 放回盒 | 上报 `EVT 0x02`,App 暂停该路并提示,戴回后恢复 | |
|||
| T11 | 走出距离边缘导致丢包 | 无崩溃、无杂音,seq 补洞,恢复后自愈 | |
|||
| T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当 | |
|||
| T13 | 重复发 `AA 6C` 三次 | 三次都回 `BB EC 00`(幂等) | |
|||
| T14 | 面对面期间尝试进 AI / 通话翻译 | 回 `ERR_BUSY`,两边状态都不被破坏 | |
|||
|
|||
--- |
|||
|
|||
## 16. 变更记录 |
|||
|
|||
| 版本 | 日期 | 变更 | |
|||
|---|---|---| |
|||
| v0.1 | 2026-09-05 | 首版草案,待固件确认 §14 | |
|||
@ -0,0 +1,117 @@ |
|||
import Foundation |
|||
|
|||
/// 广播扩展 → 主 App 的音频通道(Unix domain socket,客户端一侧)。 |
|||
/// |
|||
/// ## 为什么用 socket 而不是共享文件 |
|||
/// App Group 的共享容器确实两边都能读写,但音频是**连续流**:用文件就要自己做 |
|||
/// 环形缓冲 + 读写位点同步 + 回绕,而且主 App 只能轮询,白白加一截延迟。 |
|||
/// Unix domain socket 是内核帮我们做好的有序字节流,两边都在同一台设备上, |
|||
/// 没有任何网络开销。这也是各家屏幕共享 SDK(Agora / LiveKit 等)的标准做法。 |
|||
/// |
|||
/// ## ⚠️ sun_path 只有 104 字节 |
|||
/// App Group 容器路径形如 |
|||
/// `/private/var/mobile/Containers/Shared/AppGroup/<UUID>/`(约 70 字符), |
|||
/// 加上文件名还剩得下,但**不能再往下建子目录**,否则会静默截断、 |
|||
/// 连到一个不存在的路径上,表现为「广播开着但主 App 一个字节都收不到」。 |
|||
enum BroadcastAudio { |
|||
/// ⚠️ 改这里必须同时改三处:主 App 的 Runner.entitlements、 |
|||
/// 本扩展的 entitlements、以及 Apple 开发者后台注册的 App Group。 |
|||
/// 少改一处的表现是 `containerURL` 返回 nil,同样是静默无声。 |
|||
static let appGroupId = "group.com.yimai.eaimar" |
|||
|
|||
/// 不带子目录,直接放在容器根下,见上面 sun_path 的说明 |
|||
static let socketName = "bcast_audio.sock" |
|||
|
|||
static var socketPath: String? { |
|||
guard let dir = FileManager.default |
|||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { |
|||
return nil |
|||
} |
|||
return dir.appendingPathComponent(socketName).path |
|||
} |
|||
} |
|||
|
|||
/// 扩展侧的发送端。非阻塞连接 + 失败自动重连。 |
|||
final class BroadcastAudioSender { |
|||
private var fd: Int32 = -1 |
|||
private var lastConnectAttempt: TimeInterval = 0 |
|||
|
|||
/// 连不上时的重试间隔。 |
|||
/// |
|||
/// ⚠️ 不能每帧都试:音频帧是几十毫秒一个,每帧发起一次 connect |
|||
/// 在主 App 没起来时会把扩展的 CPU 打满,而广播扩展的内存/CPU 预算 |
|||
/// 比普通进程紧得多(内存上限 50MB,超了直接被系统杀掉)。 |
|||
private let retryInterval: TimeInterval = 1.0 |
|||
|
|||
private func ensureConnected() -> Bool { |
|||
if fd >= 0 { return true } |
|||
let now = Date().timeIntervalSince1970 |
|||
if now - lastConnectAttempt < retryInterval { return false } |
|||
lastConnectAttempt = now |
|||
|
|||
guard let path = BroadcastAudio.socketPath else { return false } |
|||
let s = socket(AF_UNIX, SOCK_STREAM, 0) |
|||
if s < 0 { return false } |
|||
|
|||
var addr = sockaddr_un() |
|||
addr.sun_family = sa_family_t(AF_UNIX) |
|||
let pathBytes = Array(path.utf8) |
|||
// 留一个字节给结尾的 \0 |
|||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else { |
|||
close(s) |
|||
return false |
|||
} |
|||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in |
|||
ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count + 1) { dst in |
|||
for (i, b) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: b) } |
|||
dst[pathBytes.count] = 0 |
|||
} |
|||
} |
|||
|
|||
let ok = withUnsafePointer(to: &addr) { p -> Bool in |
|||
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in |
|||
connect(s, sa, socklen_t(MemoryLayout<sockaddr_un>.size)) == 0 |
|||
} |
|||
} |
|||
if !ok { |
|||
close(s) |
|||
return false |
|||
} |
|||
// 主 App 挂掉时写入会收到 SIGPIPE,默认行为是**直接杀死本进程**。 |
|||
// 设成返回 EPIPE 错误,交给下面的 send 自己处理重连。 |
|||
var nosigpipe: Int32 = 1 |
|||
setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout<Int32>.size)) |
|||
fd = s |
|||
return true |
|||
} |
|||
|
|||
private func disconnect() { |
|||
if fd >= 0 { close(fd) } |
|||
fd = -1 |
|||
} |
|||
|
|||
/// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。 |
|||
/// |
|||
/// 加长度前缀是因为 SOCK_STREAM **不保留消息边界**:接收端读到的可能是 |
|||
/// 半帧、也可能是两帧粘在一起。不分帧的话喂给 ASR 的就是错位的样本。 |
|||
func send(_ data: Data) { |
|||
guard ensureConnected() else { return } |
|||
var header = UInt32(data.count).littleEndian |
|||
var payload = Data(bytes: &header, count: 4) |
|||
payload.append(data) |
|||
|
|||
let written: Int = payload.withUnsafeBytes { raw -> Int in |
|||
guard let base = raw.baseAddress else { return -1 } |
|||
var offset = 0 |
|||
while offset < payload.count { |
|||
let n = write(fd, base.advanced(by: offset), payload.count - offset) |
|||
if n <= 0 { return -1 } |
|||
offset += n |
|||
} |
|||
return offset |
|||
} |
|||
if written < 0 { disconnect() } |
|||
} |
|||
|
|||
deinit { disconnect() } |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>com.apple.security.application-groups</key> |
|||
<array> |
|||
<string>group.com.yimai.eaimar</string> |
|||
</array> |
|||
</dict> |
|||
</plist> |
|||
@ -0,0 +1,9 @@ |
|||
// 只引 Flutter 的 Generated.xcconfig,**不引** Flutter/Release.xcconfig。 |
|||
// |
|||
// ⚠️ 后者会把 Pods-Runner 的 xcconfig 一起带进来(framework search paths、 |
|||
// OTHER_LDFLAGS 里几十个 -framework),扩展根本不需要那些 pod, |
|||
// 带进来只会让链接失败或把扩展体积撑爆 —— 而广播扩展的内存上限只有 50MB。 |
|||
// |
|||
// 这里要的只有 FLUTTER_BUILD_NAME / FLUTTER_BUILD_NUMBER:扩展的版本号 |
|||
// 必须与主 App 一致,否则上架校验会被拒。取自同一个源头就不会漂。 |
|||
#include "../Flutter/Generated.xcconfig" |
|||
@ -0,0 +1,33 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>CFBundleDevelopmentRegion</key> |
|||
<string>$(DEVELOPMENT_LANGUAGE)</string> |
|||
<key>CFBundleDisplayName</key> |
|||
<string>EAIMAR 多媒体翻译</string> |
|||
<key>CFBundleExecutable</key> |
|||
<string>$(EXECUTABLE_NAME)</string> |
|||
<key>CFBundleIdentifier</key> |
|||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> |
|||
<key>CFBundleInfoDictionaryVersion</key> |
|||
<string>6.0</string> |
|||
<key>CFBundleName</key> |
|||
<string>$(PRODUCT_NAME)</string> |
|||
<key>CFBundlePackageType</key> |
|||
<string>XPC!</string> |
|||
<key>CFBundleShortVersionString</key> |
|||
<string>$(FLUTTER_BUILD_NAME)</string> |
|||
<key>CFBundleVersion</key> |
|||
<string>$(FLUTTER_BUILD_NUMBER)</string> |
|||
<key>NSExtension</key> |
|||
<dict> |
|||
<key>NSExtensionPointIdentifier</key> |
|||
<string>com.apple.broadcast-services-upload</string> |
|||
<key>NSExtensionPrincipalClass</key> |
|||
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string> |
|||
<key>RPBroadcastProcessMode</key> |
|||
<string>RPBroadcastProcessModeSampleBuffer</string> |
|||
</dict> |
|||
</dict> |
|||
</plist> |
|||
@ -0,0 +1,155 @@ |
|||
import ReplayKit |
|||
import AVFoundation |
|||
|
|||
/// 系统录屏广播的采样处理器 —— 多媒体翻译在 iOS 上拿到「其他 App 正在播放的 |
|||
/// 声音」的**唯一合规途径**。 |
|||
/// |
|||
/// ## 为什么必须是一个独立的 extension |
|||
/// iOS 不允许一个 App 读另一个 App 的音频。ReplayKit 的 Broadcast Upload |
|||
/// Extension 是系统留的唯一口子:用户从系统录屏面板里选中本扩展、开始广播, |
|||
/// 系统才会把全局的 `audioApp`(各 App 播放的混音)和 `audioMic`(麦克风) |
|||
/// 送进这个扩展进程。主 App 自己是拿不到的。 |
|||
/// |
|||
/// ## ⚠️ 只取 audioApp,丢掉 audioMic 和 video |
|||
/// - `video` 完全不要:我们只做翻译,收视频帧纯属浪费内存,而广播扩展的 |
|||
/// 内存上限是 **50MB**,超了系统直接杀进程(表现为广播莫名其妙停掉)。 |
|||
/// - `audioMic` 也不要:混进去会把用户和旁人的说话声一起送去识别, |
|||
/// 翻译结果会串成一团。 |
|||
/// |
|||
/// ## ⚠️ 采样率必须在这里转 |
|||
/// 系统给的是 44.1k/48k、立体声、Float32;Azure ASR 要 16k/单声道/Int16。 |
|||
/// 不转的话推进去的是一堆噪声,识别恒为空,且不报任何错。 |
|||
class SampleHandler: RPBroadcastSampleHandler { |
|||
private let sender = BroadcastAudioSender() |
|||
|
|||
/// 输出格式:16kHz / 单声道 / Int16 交错,与 azureAsrHelper.audioStream 的要求一致 |
|||
private let outputFormat = AVAudioFormat( |
|||
commonFormat: .pcmFormatInt16, |
|||
sampleRate: 16000, |
|||
channels: 1, |
|||
interleaved: true |
|||
)! |
|||
|
|||
/// 输入格式会变(不同 App 的输出不一样),换了就重建 converter |
|||
private var converter: AVAudioConverter? |
|||
private var converterInputFormat: AVAudioFormat? |
|||
|
|||
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { |
|||
NSLog("[BroadcastExtension] broadcastStarted") |
|||
} |
|||
|
|||
override func broadcastFinished() { |
|||
NSLog("[BroadcastExtension] broadcastFinished") |
|||
} |
|||
|
|||
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, |
|||
with sampleBufferType: RPSampleBufferType) { |
|||
guard sampleBufferType == .audioApp else { return } |
|||
guard CMSampleBufferDataIsReady(sampleBuffer) else { return } |
|||
guard let pcm = convertToTargetFormat(sampleBuffer) else { return } |
|||
sender.send(pcm) |
|||
} |
|||
|
|||
// MARK: - 格式转换 |
|||
|
|||
private func convertToTargetFormat(_ sampleBuffer: CMSampleBuffer) -> Data? { |
|||
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer), |
|||
let asbdPtr = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc) else { |
|||
return nil |
|||
} |
|||
let inFormat = AVAudioFormat(streamDescription: asbdPtr) |
|||
guard let inFormat = inFormat else { return nil } |
|||
|
|||
if converter == nil || converterInputFormat != inFormat { |
|||
converter = AVAudioConverter(from: inFormat, to: outputFormat) |
|||
converterInputFormat = inFormat |
|||
} |
|||
guard let converter = converter else { return nil } |
|||
|
|||
guard let inBuffer = makeInputBuffer(sampleBuffer, format: inFormat) else { return nil } |
|||
|
|||
// 输出容量按采样率比例算,再留一点余量:转换器允许一次多吐一点。 |
|||
// 算小了会得到 .endOfStream 并丢样本,表现是识别断断续续。 |
|||
let ratio = outputFormat.sampleRate / inFormat.sampleRate |
|||
let capacity = AVAudioFrameCount(Double(inBuffer.frameLength) * ratio) + 1024 |
|||
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, |
|||
frameCapacity: capacity) else { |
|||
return nil |
|||
} |
|||
|
|||
var consumed = false |
|||
var error: NSError? |
|||
converter.convert(to: outBuffer, error: &error) { _, status in |
|||
if consumed { |
|||
status.pointee = .noDataNow |
|||
return nil |
|||
} |
|||
consumed = true |
|||
status.pointee = .haveData |
|||
return inBuffer |
|||
} |
|||
if let error = error { |
|||
NSLog("[BroadcastExtension] 音频转换失败: %@", error.localizedDescription) |
|||
return nil |
|||
} |
|||
guard outBuffer.frameLength > 0, |
|||
let channelData = outBuffer.int16ChannelData else { return nil } |
|||
let byteCount = Int(outBuffer.frameLength) * MemoryLayout<Int16>.size |
|||
return Data(bytes: channelData[0], count: byteCount) |
|||
} |
|||
|
|||
/// CMSampleBuffer → AVAudioPCMBuffer(**深拷贝**)。 |
|||
/// |
|||
/// ⚠️ `CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer` 并不会把样本 |
|||
/// 拷进你给的 buffer,它只是把 AudioBufferList 里的 `mData` **指向 blockBuffer |
|||
/// 持有的内存**。blockBuffer 是个局部变量,函数一返回就被 ARC 释放, |
|||
/// 把那份指针交出去就是野指针 —— 表现是随机崩溃,而且多半崩在转换器里, |
|||
/// 看上去像 AVAudioConverter 的锅。所以这里必须在 blockBuffer 活着的时候 |
|||
/// 就把数据 memcpy 出来。 |
|||
/// |
|||
/// ⚠️ AudioBufferList 的 buffer 个数要按格式算:非交错多声道是「每声道一个 |
|||
/// buffer」,交错才是一个。写死成 1 时,ReplayKit 给的非交错立体声会让 |
|||
/// 上面那个调用直接返回错误码,一帧都拿不到。 |
|||
private func makeInputBuffer(_ sampleBuffer: CMSampleBuffer, |
|||
format: AVAudioFormat) -> AVAudioPCMBuffer? { |
|||
let frameCount = AVAudioFrameCount(CMSampleBufferGetNumSamples(sampleBuffer)) |
|||
guard frameCount > 0 else { return nil } |
|||
|
|||
let maxBuffers = format.isInterleaved ? 1 : Int(format.channelCount) |
|||
let srcList = AudioBufferList.allocate(maximumBuffers: maxBuffers) |
|||
defer { free(srcList.unsafeMutablePointer) } |
|||
|
|||
var blockBuffer: CMBlockBuffer? |
|||
let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( |
|||
sampleBuffer, |
|||
bufferListSizeNeededOut: nil, |
|||
bufferListOut: srcList.unsafeMutablePointer, |
|||
bufferListSize: AudioBufferList.sizeInBytes(maximumBuffers: maxBuffers), |
|||
blockBufferAllocator: kCFAllocatorDefault, |
|||
blockBufferMemoryAllocator: kCFAllocatorDefault, |
|||
flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, |
|||
blockBufferOut: &blockBuffer |
|||
) |
|||
guard status == noErr, blockBuffer != nil else { return nil } |
|||
|
|||
guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { |
|||
return nil |
|||
} |
|||
out.frameLength = frameCount |
|||
|
|||
// withExtendedLifetime:把 blockBuffer 的生命周期显式压到拷贝之后。 |
|||
// 光靠一个 `_ = blockBuffer` 是不保证的,优化器可以提前释放。 |
|||
let copied: Bool = withExtendedLifetime(blockBuffer) { () -> Bool in |
|||
let dstList = UnsafeMutableAudioBufferListPointer(out.mutableAudioBufferList) |
|||
guard dstList.count == srcList.count else { return false } |
|||
for i in 0..<srcList.count { |
|||
guard let src = srcList[i].mData, let dst = dstList[i].mData else { return false } |
|||
let n = min(Int(srcList[i].mDataByteSize), Int(dstList[i].mDataByteSize)) |
|||
memcpy(dst, src, n) |
|||
dstList[i].mDataByteSize = UInt32(n) |
|||
} |
|||
return true |
|||
} |
|||
return copied ? out : nil |
|||
} |
|||
} |
|||
@ -0,0 +1,146 @@ |
|||
import Foundation |
|||
|
|||
/// 主 App 侧:接收录屏广播扩展送来的系统音频 PCM。 |
|||
/// |
|||
/// 对端是 `ios/BroadcastExtension/BroadcastAudioSocket.swift` 里的 |
|||
/// `BroadcastAudioSender`。协议:Unix domain socket,帧格式 |
|||
/// **4 字节小端长度 + 16kHz/单声道/Int16 交错的 PCM**。 |
|||
/// |
|||
/// ## ⚠️ 这里的常量与扩展那份是**手工同步**的 |
|||
/// 两边分属不同编译单元(扩展在 Runner 工程里,本文件在 azure_speech 这个 |
|||
/// SwiftPM 包里),没有共享代码的干净办法,所以 appGroupId 与 socketName |
|||
/// 是抄了一份。改任何一个都要两边一起改,**改漏了不会编译报错**, |
|||
/// 只会表现为「广播开着,主 App 一个字节都收不到」。 |
|||
/// |
|||
/// ## ⚠️ 主 App 必须活着 |
|||
/// 用户开始广播后会切到视频 App,我们退到后台。能继续跑是因为 |
|||
/// Info.plist 的 UIBackgroundModes 里有 `audio` 且此时会话是激活的 |
|||
/// (多媒体翻译本来就在用 .playAndRecord)。会话一旦被系统收走, |
|||
/// 进程被挂起,socket 读循环就停了 —— 广播还开着但没有识别结果。 |
|||
public final class BroadcastAudioReceiver { |
|||
public static let shared = BroadcastAudioReceiver() |
|||
|
|||
/// 与扩展侧保持一致,见上面的同步警告 |
|||
private static let appGroupId = "group.com.yimai.eaimar" |
|||
private static let socketName = "bcast_audio.sock" |
|||
|
|||
private var listenFd: Int32 = -1 |
|||
private var clientFd: Int32 = -1 |
|||
private var running = false |
|||
private let queue = DispatchQueue(label: "com.yimai.eaimar.broadcast-audio") |
|||
|
|||
/// 收到一帧 PCM 时回调(已经是 16k/mono/Int16) |
|||
public var onPcm: ((Data) -> Void)? |
|||
|
|||
private init() {} |
|||
|
|||
public var socketPath: String? { |
|||
guard let dir = FileManager.default |
|||
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupId) else { |
|||
return nil |
|||
} |
|||
return dir.appendingPathComponent(Self.socketName).path |
|||
} |
|||
|
|||
@discardableResult |
|||
public func start() -> Bool { |
|||
if running { return true } |
|||
guard let path = socketPath else { |
|||
NSLog("[BroadcastAudioReceiver] 拿不到 App Group 容器,检查 entitlements 与开发者后台的 App Group 登记") |
|||
return false |
|||
} |
|||
// ⚠️ 必须先删旧的:bind 到一个已存在的路径会直接 EADDRINUSE。 |
|||
// 上一次进程被杀时不会自动清理,所以每次都清一遍。 |
|||
unlink(path) |
|||
|
|||
let fd = socket(AF_UNIX, SOCK_STREAM, 0) |
|||
if fd < 0 { |
|||
NSLog("[BroadcastAudioReceiver] socket() 失败: %d", errno) |
|||
return false |
|||
} |
|||
var addr = sockaddr_un() |
|||
addr.sun_family = sa_family_t(AF_UNIX) |
|||
let pathBytes = Array(path.utf8) |
|||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else { |
|||
// sun_path 只有 104 字节,App Group 容器路径已经占了 ~70 |
|||
NSLog("[BroadcastAudioReceiver] socket 路径过长: %d", pathBytes.count) |
|||
close(fd) |
|||
return false |
|||
} |
|||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in |
|||
ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count + 1) { dst in |
|||
for (i, b) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: b) } |
|||
dst[pathBytes.count] = 0 |
|||
} |
|||
} |
|||
let bound = withUnsafePointer(to: &addr) { p -> Bool in |
|||
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in |
|||
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size)) == 0 |
|||
} |
|||
} |
|||
guard bound, listen(fd, 1) == 0 else { |
|||
NSLog("[BroadcastAudioReceiver] bind/listen 失败: %d", errno) |
|||
close(fd) |
|||
return false |
|||
} |
|||
listenFd = fd |
|||
running = true |
|||
NSLog("[BroadcastAudioReceiver] 已监听 %@", path) |
|||
queue.async { [weak self] in self?.acceptLoop() } |
|||
return true |
|||
} |
|||
|
|||
public func stop() { |
|||
running = false |
|||
if clientFd >= 0 { close(clientFd); clientFd = -1 } |
|||
if listenFd >= 0 { close(listenFd); listenFd = -1 } |
|||
if let path = socketPath { unlink(path) } |
|||
NSLog("[BroadcastAudioReceiver] 已停止") |
|||
} |
|||
|
|||
// MARK: - 私有 |
|||
|
|||
private func acceptLoop() { |
|||
while running { |
|||
let c = accept(listenFd, nil, nil) |
|||
if c < 0 { |
|||
if running { usleep(100_000) } |
|||
continue |
|||
} |
|||
clientFd = c |
|||
NSLog("[BroadcastAudioReceiver] 广播扩展已连接") |
|||
readLoop(c) |
|||
close(c) |
|||
clientFd = -1 |
|||
NSLog("[BroadcastAudioReceiver] 广播扩展断开,等待重连") |
|||
} |
|||
} |
|||
|
|||
private func readLoop(_ fd: Int32) { |
|||
var header = [UInt8](repeating: 0, count: 4) |
|||
while running { |
|||
guard readFully(fd, &header, 4) else { return } |
|||
let len = Int(UInt32(header[0]) | (UInt32(header[1]) << 8) |
|||
| (UInt32(header[2]) << 16) | (UInt32(header[3]) << 24)) |
|||
// 一帧 16k/mono/Int16 的音频远小于 1MB。收到离谱的长度说明帧同步 |
|||
// 已经错位(例如对端半途被杀),继续读只会一直错下去,直接断开重来。 |
|||
guard len > 0, len <= 1 << 20 else { |
|||
NSLog("[BroadcastAudioReceiver] 帧长度异常 %d,断开重连", len) |
|||
return |
|||
} |
|||
var payload = [UInt8](repeating: 0, count: len) |
|||
guard readFully(fd, &payload, len) else { return } |
|||
onPcm?(Data(payload)) |
|||
} |
|||
} |
|||
|
|||
private func readFully(_ fd: Int32, _ buf: UnsafeMutablePointer<UInt8>, _ count: Int) -> Bool { |
|||
var got = 0 |
|||
while got < count { |
|||
let n = read(fd, buf.advanced(by: got), count - got) |
|||
if n <= 0 { return false } |
|||
got += n |
|||
} |
|||
return true |
|||
} |
|||
} |
|||
Loading…
Reference in new issue