From 83777459929e8ad7b4555db192baea79106b7e75 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 21:15:55 +0800 Subject: [PATCH] =?UTF-8?q?iOS=20=E5=A4=9A=E5=AA=92=E4=BD=93=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E6=8E=A5=E5=85=A5=20ReplayKit=20=E5=B9=BF=E6=92=AD?= =?UTF-8?q?=E6=89=A9=E5=B1=95=EF=BC=8C=E7=9C=9F=E6=AD=A3=E6=8B=BF=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=9F=B3=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/client/docs/face_to_face_ble_protocol.md | 491 ++++++++++++++++++ .../BroadcastAudioSocket.swift | 117 +++++ .../BroadcastExtension.entitlements | 10 + .../ios/BroadcastExtension/Extension.xcconfig | 9 + apps/client/ios/BroadcastExtension/Info.plist | 33 ++ .../BroadcastExtension/SampleHandler.swift | 155 ++++++ .../ios/Runner.xcodeproj/project.pbxproj | 190 +++++++ apps/client/ios/Runner/Runner.entitlements | 7 + .../speech_impl/azure_asr_service.dart | 40 ++ .../controllers/translation_controller.dart | 45 +- .../azure_speech/AzureSpeechPlugin.swift | 68 +++ .../azure_speech/BroadcastAudioReceiver.swift | 146 ++++++ .../Sources/tools/SimpleAudioReceiver.swift | 35 +- 13 files changed, 1308 insertions(+), 38 deletions(-) create mode 100644 apps/client/docs/face_to_face_ble_protocol.md create mode 100644 apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift create mode 100644 apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements create mode 100644 apps/client/ios/BroadcastExtension/Extension.xcconfig create mode 100644 apps/client/ios/BroadcastExtension/Info.plist create mode 100644 apps/client/ios/BroadcastExtension/SampleHandler.swift create mode 100644 apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift diff --git a/apps/client/docs/face_to_face_ble_protocol.md b/apps/client/docs/face_to_face_ble_protocol.md new file mode 100644 index 00000000..b3834927 --- /dev/null +++ b/apps/client/docs/face_to_face_ble_protocol.md @@ -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 │ │ + │───────────────────────────────────────►│ │ + │ │ 切工作模式:停A2DP/HFP│ + │ ② SPP: BB EB ... │ 起 BLE 广播(带 token) │ + │◄───────────────────────────────────────│ │ + │ │ │ + │ ③ BLE 扫描 → 按 token 过滤 → 连接 │ │ + │ ④ MTU 交换 / 2M PHY / 订阅通知 │ │ + │───────────────────────────────────────►│ │ + │ │ │ + │ ⑤ BLE: AA 01 HELLO │ │ + │───────────────────────────────────────►│ │ + │ ⑥ BLE: BB 81 HELLO_ACK ... │ │ + │◄───────────────────────────────────────│ │ + │ ⑦ 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 │ │ + │◄───────────────────────────────────────│ │ +``` + +**为什么入口放在 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` | 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` | 见 §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 +``` + +⚠️ **必须幂等**:不在面对面模式时收到 `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 ` / `BB `,`LEN` 为 payload 字节数。 + +| 步骤 | 帧 | payload | 说明 | +|---|---|---|---| +| ① HELLO | `AA 01` | `` | `ROLE`:`0x01`=App。token 再校验一次,防止连错 | +| ② HELLO_ACK | `BB 81` | `` | `EAR` 必须与广播里一致;`MTU_OK`=0 时 App 降级到 20ms 单帧包 | +| ③ SESSION_CFG | `AA 02` | `` | 见下 | +| ④ CFG_ACK | `BB 82` | `` | | +| ⑤ START_STREAM | `AA 03` | `` | 指定开哪几路上行;档位 B 下固定为本耳 | +| ⑥ START_ACK | `BB 83` | `` | 回 `0x00` 后耳机开始推 `BB 90` | +| ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) | +| ⑧ KEEPALIVE | `AA 05` | `` | App 每 **1s** 发一次 | +| ⑨ KEEPALIVE_ACK | `BB 85` | `` | `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 +``` + +| 字段 | 说明 | +|---|---| +| `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 +``` + +| 字段 | 说明 | +|---|---| +| `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 ` 请求改节奏 + (语义对齐现有 SPP 的 `0xD6/0xE9` 调速),合法值 `16 / 20 / 25 / 40`。 +- 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。 + +--- + +## 10. 事件上报 + +``` +BB 87 +``` + +| `EVENT` | 含义 | PARAM | App 侧对应 | +|---|---|---|---| +| `0x01` | 按键 | ``,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller` 落地,固件只需如实上报 | +| `0x02` | 佩戴状态 | ``,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 | +| `0x03` | 电量 | ``,最高位为充电位 | 与 `BB 0A` 同口径 | +| `0x04` | 链路统计 | `` | 每 10s 一次,仅用于诊断日志 | +| `0x05` | 异常 | ``,见 §12 | 缓冲溢出、编码失败等 | +| `0x06` | 耳机请求退出 | `` | 用户在耳机上操作或电量过低;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 | diff --git a/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift b/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift new file mode 100644 index 00000000..29aeb73e --- /dev/null +++ b/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift @@ -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//`(约 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.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.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() } +} diff --git a/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements b/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements new file mode 100644 index 00000000..1f0294d4 --- /dev/null +++ b/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.yimai.eaimar + + + diff --git a/apps/client/ios/BroadcastExtension/Extension.xcconfig b/apps/client/ios/BroadcastExtension/Extension.xcconfig new file mode 100644 index 00000000..60e0107c --- /dev/null +++ b/apps/client/ios/BroadcastExtension/Extension.xcconfig @@ -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" diff --git a/apps/client/ios/BroadcastExtension/Info.plist b/apps/client/ios/BroadcastExtension/Info.plist new file mode 100644 index 00000000..d16db307 --- /dev/null +++ b/apps/client/ios/BroadcastExtension/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + EAIMAR 多媒体翻译 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.broadcast-services-upload + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SampleHandler + RPBroadcastProcessMode + RPBroadcastProcessModeSampleBuffer + + + diff --git a/apps/client/ios/BroadcastExtension/SampleHandler.swift b/apps/client/ios/BroadcastExtension/SampleHandler.swift new file mode 100644 index 00000000..46bce69e --- /dev/null +++ b/apps/client/ios/BroadcastExtension/SampleHandler.swift @@ -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.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.. applinks:yimai.games + com.apple.security.application-groups + + + group.com.yimai.eaimar + com.apple.developer.networking.wifi-info com.apple.external-accessory.wireless-configuration diff --git a/apps/client/lib/data/services/speech_impl/azure_asr_service.dart b/apps/client/lib/data/services/speech_impl/azure_asr_service.dart index d0561538..f3a22303 100644 --- a/apps/client/lib/data/services/speech_impl/azure_asr_service.dart +++ b/apps/client/lib/data/services/speech_impl/azure_asr_service.dart @@ -558,8 +558,37 @@ class AzureAsrService extends GetxService implements AsrService { } } + /// 请求「系统音频捕获」。两端拿到系统音频的机制完全不同,但对上层是同一件事: + /// + /// - **Android**:MediaProjection。弹一次录屏授权,同意后系统播放流直接进 ASR。 + /// 返回值就是用户同意与否。 + /// - **iOS**:ReplayKit 的 Broadcast Upload Extension(唯一合规途径, + /// iOS 不允许一个 App 读另一个 App 的音频)。这里做两件事:把主 App 侧的 + /// socket 接收端起起来,然后弹出系统的「开始广播」面板。 + /// + /// ⚠️ iOS 的返回值只代表「面板弹出来了」,**不代表用户真的开始了广播**。 + /// 系统没有给出可查询的回调,我们只能等 PCM 是否到达来判断。所以调用方 + /// 不要把 true 当成「已经在收音了」。 @override Future requestScreenCapture() async { + if (Platform.isIOS) { + try { + // 顺序不能反:先监听再弹面板。反过来的话用户手快, + // 扩展先起来连不上 socket,要等它 1 秒后重试才接得上。 + final bool started = (await _channel + .invokeMethod('startBroadcastAudioReceiver')) ?? + false; + if (!started) { + Logger.error('广播音频接收端启动失败(多半是 App Group 没配好)'); + return false; + } + await _channel.invokeMethod('presentBroadcastPicker'); + return true; + } catch (e) { + Logger.error('iOS 系统音频捕获启动失败: ${e.toString()}'); + return false; + } + } if (!Platform.isAndroid) return false; try { final bool ok = @@ -573,6 +602,17 @@ class AzureAsrService extends GetxService implements AsrService { @override Future stopScreenCapture() async { + if (Platform.isIOS) { + try { + // 只关我们这一端。广播本身是用户从系统面板/灵动岛停的, + // App 无权强停——扩展会收到 broadcastFinished 自己退出。 + await _channel.invokeMethod('stopBroadcastAudioReceiver'); + return true; + } catch (e) { + Logger.error('停止广播音频接收失败: ${e.toString()}'); + return false; + } + } if (!Platform.isAndroid) return true; try { final bool ok = diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index ed30c44b..f8add2d7 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -1197,12 +1197,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _astService.enableRecord(filePath); } else { AudioSourceType audioSourceType; - if (currentMode.value == 'audioVideo' && Platform.isAndroid) { + if (currentMode.value == 'audioVideo') { audioSourceType = AudioSourceType.systemAudio; } else { - // ⚠️ iOS 的音视频翻译已经改成手机麦克风收音(见 _configureAudioVideoMode), - // 录音源必须跟着改。留 systemAudio 的话,原生把 index>0 一律当成 - // "external",会建好文件然后等一份没人推的 PCM —— 存下来是个空录音。 audioSourceType = AudioSourceType.microphone; } await _asrService.enableRecord(audioSourceType, filePath, false); @@ -1422,7 +1419,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // Android 音视频翻译:识别就绪后才弹"录屏"授权 — 用户同意的瞬间 // 系统音频开始流入已经准备好的 ASR 通路,可以立即切到目标 App。 - if (isAndroidAudioVideo) { + if (isAndroidAudioVideo || isIosAudioVideo) { + // iOS:起 socket 接收端 + 弹系统「开始广播」面板。 + // ⚠️ 返回 true 只代表面板弹出来了,**不代表用户真的开始了广播**—— + // 系统没有可查询的回调。所以下面的回滚分支在 iOS 上只会在 + // 「接收端都没起起来」(App Group 没配好)时触发。 final captured = await _asrService.requestScreenCapture(); if (!captured) { // 拒绝则回滚已经启动的识别 + 关掉浮窗,避免空浮窗悬空在桌面 @@ -1538,24 +1539,21 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 让用户立刻就能切到 YouTube 等 App 看翻译结果。 isPreparing.value = false; } else { - // ⚠️ iOS 上**没有**「抓取其他 App 音频」这条路。 + // iOS 走 ReplayKit 录屏广播扩展拿系统音频(唯一合规途径,iOS 不允许 + // 一个 App 读另一个 App 的音频)。真正的启动排在 startRecognition 末尾, + // 与 Android 弹录屏授权的时机一致:先把识别通路准备好,最后才请用户 + // 开始广播,同意的瞬间音频就有地方去。 // - // 这里原来的写法是 openDecoder() + 等 isCodecActive + 音频源置 external, - // 那是杰理时代的假设:音视频翻译 = 从耳机的 A2DP 解码器拿手机正在放的音乐。 - // 本项目只有恒玄耳机(没有这个能力),而 openDecoder() 现在是个乐观桩, - // 它只是把 _isCodecActive 置 true 就返回。于是 iOS 上的实际行为是: - // ASR 以 external 源启动,然后**等一份永远不会有人推的 PCM**—— - // 不报错、不提示,就是一个字都翻不出来。 - // - // 退化成手机麦克风:用户外放视频,我们从空气里收。这不是等价替代, - // 音质和抗噪都差一截,但至少功能是通的。要真正拿到系统音频, - // iOS 只有 ReplayKit Broadcast Upload Extension 一条路(需要新建 extension - // target + App Group,且要用户手动发起录屏广播),那是产品决策。 + // ⚠️ 这里**不能**再调 openDecoder()。那是杰理时代的写法(音视频翻译 = + // 从耳机 A2DP 解码器拿手机正在放的音乐),本项目只有恒玄耳机、没这个能力; + // 而 openDecoder() 如今是个乐观桩,只把 _isCodecActive 置 true 就返回, + // 于是 ASR 以 external 源起来之后**等一份永远没人推的 PCM**—— + // 不报错、不提示,一个字都翻不出来。这正是 iOS 多媒体翻译此前全哑的原因。 isPreparing.value = false; } - // Android: true = external,PCM 由 MediaProjection 推上来。 - // iOS: false = 麦克风。 - _audioSourceType = Platform.isAndroid; + // 两端都是 external:PCM 由系统音频捕获推上来 + // (Android = MediaProjection,iOS = 录屏广播扩展)。 + _audioSourceType = true; isTtsEnabled.value = false; Logger.info('发送ble音乐或者通话远端声音'); } @@ -2620,8 +2618,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _teardownBesCallMode(); } else { _detachJieliCallTranslationBridge(); - if (currentMode.value == "audioVideo" && Platform.isAndroid) { - // Android 音视频翻译走 MediaProjection,关掉对应前台服务即可。 + if (currentMode.value == "audioVideo" && + (Platform.isAndroid || Platform.isIOS)) { + // Android:关掉 MediaProjection 的前台服务。 + // iOS:只关我们这一端的 socket 接收;广播本身得用户从系统面板/灵动岛 + // 自己停,App 无权强停(扩展会收到 broadcastFinished 自行退出)。 await _asrService.stopScreenCapture(); } else { bleManager.closeCodec(); diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index a4065fe8..eb581dea 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -1,6 +1,7 @@ import Flutter import UIKit import AVFoundation +import ReplayKit import MicrosoftCognitiveServicesSpeech // 自定义语音处理组件,提供音频流处理等功能 import speech @@ -616,6 +617,73 @@ private func sendAudioDataEvent(_ event: [String: Any]) { currentAsrCallback = nil result(true) + // ===== 多媒体翻译:系统音频(录屏广播扩展 → 主 App) ===== + // + // iOS 不允许一个 App 读另一个 App 的音频,ReplayKit 的 Broadcast Upload + // Extension 是唯一合规途径。扩展在自己的进程里收系统混音、转成 + // 16k/mono/Int16,经 App Group 容器下的 Unix socket 送到这里, + // 再灌进 ASR 已有的 external 音频流(与 pushAudioData 同一个入口)。 + case "startBroadcastAudioReceiver": + let receiver = BroadcastAudioReceiver.shared + receiver.onPcm = { [weak self] pcm in + // ⚠️ 这里在 socket 读线程上。saveAudioDataTo 内部是把数据丢进 + // Azure 的 push stream,本身线程安全;**不要**为了"保险"切到主线程, + // 音频是连续流,切一次主线程就多一次调度抖动,还可能被 UI 阻塞。 + guard let stream = self?.azureAsrHelper.audioStream else { return } + stream.saveAudioDataTo(data: pcm) + } + let started = receiver.start() + os_log("startBroadcastAudioReceiver: %d", log: ctLog, type: .info, started ? 1 : 0) + result(started) + + case "stopBroadcastAudioReceiver": + BroadcastAudioReceiver.shared.onPcm = nil + BroadcastAudioReceiver.shared.stop() + result(true) + + // 弹出系统的"开始广播"选择面板。 + // + // ⚠️ RPSystemBroadcastPickerView 没有公开的"打开面板"API,只能把它加进 + // 视图层级、找到内部那颗 UIButton 再发一次点击。这是各家屏幕共享 SDK + // 通用的做法,App Store 可接受。找不到按钮时把 view 留在屏幕上让用户 + // 自己点,**不要静默失败** —— 否则用户点了"开始"什么都不会发生。 + case "presentBroadcastPicker": + if #available(iOS 12.0, *) { + let bundleId = (Bundle.main.bundleIdentifier ?? "") + ".BroadcastExtension" + // 先取出来再进闭包:直接在闭包里引用实例属性 ctLog 会要求写 + // self.ctLog,那样闭包就顺手把整个 plugin 强引用进去了。 + let log = ctLog + DispatchQueue.main.async { + let picker = RPSystemBroadcastPickerView( + frame: CGRect(x: 0, y: 0, width: 60, height: 60)) + picker.preferredExtension = bundleId + picker.showsMicrophoneButton = false + // 挂到窗口上但推到屏幕外:不这么做,某些系统版本上按钮点了没反应 + if let window = UIApplication.shared.windows.first { + picker.alpha = 0.01 + window.addSubview(picker) + } + var tapped = false + for sub in picker.subviews { + if let button = sub as? UIButton { + button.sendActions(for: .touchUpInside) + tapped = true + break + } + } + os_log("presentBroadcastPicker: ext=%@ tapped=%d", + log: log, type: .info, bundleId, tapped ? 1 : 0) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + picker.removeFromSuperview() + } + } + result(true) + } else { + result(FlutterError(code: "UNSUPPORTED", + message: "系统音频翻译需要 iOS 12 及以上", + details: nil)) + } + case "pushAudioData": guard let args = call.arguments as? [String: Any], let audioBytes = args["data"] as? FlutterStandardTypedData else { diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift new file mode 100644 index 00000000..4486fb9c --- /dev/null +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift @@ -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.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, _ 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 + } +} diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index d8ded533..41546667 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -187,23 +187,26 @@ public class SimpleAudioReceiver: NSObject { // 这样耳机以经典蓝牙配对过的话 TTS 走 A2DP;没配过的话回退手机扬声器 // —— 跟 Android 行为对齐。 - if audioSourceType == .microphone { + // ⚠️ media(多媒体翻译)必须排在 audioSourceType 判断**之前**。 + // + // 这个模式下音频来自录屏广播扩展(external 源),但会话配置不能用 + // external 那套 `.playback`:那会打断正在播放的那个 App —— 用户点开 + // 翻译,视频就哑了,功能自相矛盾。`.mixWithOthers` 是这条路能成立的前提。 + // + // 用 `.playAndRecord` 而不是纯 `.playback`,是为了让 App 在切到后台 + // (用户去看视频)之后仍然活着继续消费 socket 上的 PCM; + // 配合 Info.plist 的 UIBackgroundModes: audio。 + // + // ⚠️ mode 用 `.default` 不用 `.voiceChat`:后者会让 MicrophoneCapture + // 打开 setVoiceProcessingEnabled(回声消除)。万一退化到麦克风采集, + // 要收的恰恰是本机扬声器的声音,AEC 会把它当回声精确消掉,一片安静。 + if currentRecognitionMode == "media" { + desiredCategory = .playAndRecord + desiredMode = .default + desiredOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP] + } else if audioSourceType == .microphone { // --- 麦克风输入逻辑 (包括混合模式) --- - if currentRecognitionMode == "media" { - // 多媒体翻译:用户在别的 App 里放视频,我们用麦克风把外放的声音收进来。 - // - // ⚠️ `.mixWithOthers` 是这条路能成立的前提。不加的话,我们一 - // `setActive(true)` 就会打断/停掉正在播放的那个 App —— 用户点开 - // 翻译,视频就哑了,等于功能自相矛盾。 - // - // ⚠️ mode 用 `.default` 不用 `.voiceChat`:`.voiceChat` 会让 - // MicrophoneCapture 打开 setVoiceProcessingEnabled(回声消除), - // 而这里要收的**恰恰就是本机扬声器放出来的声音**,AEC 会把它当回声 - // 精确地消掉,结果就是一片安静、一个字都识别不出来。 - desiredCategory = .playAndRecord - desiredMode = .default - desiredOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP] - } else if currentRecognitionMode == "phone_call" { + if currentRecognitionMode == "phone_call" { if isHeadphones { desiredCategory = .playAndRecord desiredMode = .default