Browse Source

iOS 多媒体翻译接入 ReplayKit 广播扩展,真正拿系统音频

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
Rodger-Wang 1 month ago
parent
commit
8377745992
  1. 491
      apps/client/docs/face_to_face_ble_protocol.md
  2. 117
      apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift
  3. 10
      apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements
  4. 9
      apps/client/ios/BroadcastExtension/Extension.xcconfig
  5. 33
      apps/client/ios/BroadcastExtension/Info.plist
  6. 155
      apps/client/ios/BroadcastExtension/SampleHandler.swift
  7. 190
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  8. 7
      apps/client/ios/Runner/Runner.entitlements
  9. 40
      apps/client/lib/data/services/speech_impl/azure_asr_service.dart
  10. 45
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  11. 68
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  12. 146
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift
  13. 27
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

491
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 <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 |

117
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/<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() }
}

10
apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements

@ -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>

9
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"

33
apps/client/ios/BroadcastExtension/Info.plist

@ -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>

155
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<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
}
}

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

@ -7,9 +7,12 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
13CB095E26EF481E91B22072 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC6000A72FC544A1C10F772 /* SampleHandler.swift */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
40C3EA883635C0BB79A8758B /* BroadcastAudioSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BDA6B1A54D93FE6A784DF3 /* BroadcastAudioSocket.swift */; };
5C7A7A7E7CC1AF04A521ADCB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B132B19743B64127CBB7007D /* Foundation.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@ -17,6 +20,7 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
99D9F83B2EDAE83B002D852E /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99D9F83A2EDAE83B002D852E /* StoreKit.framework */; }; 99D9F83B2EDAE83B002D852E /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99D9F83A2EDAE83B002D852E /* StoreKit.framework */; };
9B1423ECCD573E1777AAE79D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */; }; 9B1423ECCD573E1777AAE79D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */; };
BE00ED3053C04BB0DAD6667F /* BroadcastExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 529AACAD0ACFFB427B535F31 /* BroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E977CA22F136C6274B5E8045 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */; }; E977CA22F136C6274B5E8045 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */; };
EA1A0002EA1A0002EA1A0002 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EA1A0001EA1A0001EA1A0001 /* GoogleService-Info.plist */; }; EA1A0002EA1A0002EA1A0002 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EA1A0001EA1A0001EA1A0001 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -29,6 +33,13 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner; remoteInfo = Runner;
}; };
3ED5946E30BC57EFC4F6074A /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 65C61F0780C678B6CFE7CD08;
remoteInfo = BroadcastExtension;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -42,9 +53,21 @@
name = "Embed Frameworks"; name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A27D348DCC38C7F09624EFEC /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
BE00ED3053C04BB0DAD6667F /* BroadcastExtension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
09CCEF013C45094883072F12 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1375FEC8C9F4FDBB6E1233D7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 1375FEC8C9F4FDBB6E1233D7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
@ -54,9 +77,13 @@
4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EB1917D772A7B4F7CC2F4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; 459EB1917D772A7B4F7CC2F4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
529AACAD0ACFFB427B535F31 /* BroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
631B7F2F99632A92739CF4CC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; 631B7F2F99632A92739CF4CC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
6BC6000A72FC544A1C10F772 /* SampleHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Extension.xcconfig; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
75F9C6FC394EA5D10CEE9FE1 /* BroadcastExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = BroadcastExtension.entitlements; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -67,6 +94,8 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
99D9F83A2EDAE83B002D852E /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 99D9F83A2EDAE83B002D852E /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
B132B19743B64127CBB7007D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
B2BDA6B1A54D93FE6A784DF3 /* BroadcastAudioSocket.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BroadcastAudioSocket.swift; sourceTree = "<group>"; };
BEEFDEAD0F00BA5E00112233 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = "<group>"; }; BEEFDEAD0F00BA5E00112233 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = "<group>"; };
C0717AA86CBDA73097E54CBE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; C0717AA86CBDA73097E54CBE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
DBAA97C281D97CBAE2D9EA89 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; DBAA97C281D97CBAE2D9EA89 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
@ -95,9 +124,30 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
9A30F6E51E3033E4DA10B3F3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C7A7A7E7CC1AF04A521ADCB /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0EA1DDC834EB32392DEDE028 /* BroadcastExtension */ = {
isa = PBXGroup;
children = (
6BC6000A72FC544A1C10F772 /* SampleHandler.swift */,
B2BDA6B1A54D93FE6A784DF3 /* BroadcastAudioSocket.swift */,
09CCEF013C45094883072F12 /* Info.plist */,
75F9C6FC394EA5D10CEE9FE1 /* BroadcastExtension.entitlements */,
6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */,
);
name = BroadcastExtension;
path = BroadcastExtension;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = { 331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -106,6 +156,14 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
6EA59762AC85078082777EE6 /* iOS */ = {
isa = PBXGroup;
children = (
B132B19743B64127CBB7007D /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -128,6 +186,7 @@
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
AE23C56B0279A26EDB8B3500 /* Pods */, AE23C56B0279A26EDB8B3500 /* Pods */,
AB638FFF65D8BF7743AA5A4E /* Frameworks */, AB638FFF65D8BF7743AA5A4E /* Frameworks */,
0EA1DDC834EB32392DEDE028 /* BroadcastExtension */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -136,6 +195,7 @@
children = ( children = (
97C146EE1CF9000F007C117D /* Runner.app */, 97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
529AACAD0ACFFB427B535F31 /* BroadcastExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -164,6 +224,7 @@
99D9F83A2EDAE83B002D852E /* StoreKit.framework */, 99D9F83A2EDAE83B002D852E /* StoreKit.framework */,
4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */, 4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */,
4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */, 4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */,
6EA59762AC85078082777EE6 /* iOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@ -203,6 +264,23 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
65C61F0780C678B6CFE7CD08 /* BroadcastExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 8D6F58914ACA89FE06071B10 /* Build configuration list for PBXNativeTarget "BroadcastExtension" */;
buildPhases = (
4655015F58C22ADC936176C7 /* Sources */,
9A30F6E51E3033E4DA10B3F3 /* Frameworks */,
8150685273C6E3E9B209305E /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = BroadcastExtension;
productName = BroadcastExtension;
productReference = 529AACAD0ACFFB427B535F31 /* BroadcastExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
@ -213,6 +291,7 @@
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
A27D348DCC38C7F09624EFEC /* Embed App Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B6CA63E72817925CE1A152C3 /* [CP] Embed Pods Frameworks */, B6CA63E72817925CE1A152C3 /* [CP] Embed Pods Frameworks */,
2B77994D45EA6E60E4E61767 /* [CP] Copy Pods Resources */, 2B77994D45EA6E60E4E61767 /* [CP] Copy Pods Resources */,
@ -220,6 +299,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
1B02837591DDB55A2346AB52 /* PBXTargetDependency */,
); );
name = Runner; name = Runner;
packageProductDependencies = ( packageProductDependencies = (
@ -267,6 +347,7 @@
targets = ( targets = (
97C146ED1CF9000F007C117D /* Runner */, 97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */, 331C8080294A63A400263BE5 /* RunnerTests */,
65C61F0780C678B6CFE7CD08 /* BroadcastExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -279,6 +360,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
8150685273C6E3E9B209305E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = { 97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -414,6 +502,15 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
4655015F58C22ADC936176C7 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13CB095E26EF481E91B22072 /* SampleHandler.swift in Sources */,
40C3EA883635C0BB79A8758B /* BroadcastAudioSocket.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = { 97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -426,6 +523,12 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
1B02837591DDB55A2346AB52 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = BroadcastExtension;
target = 65C61F0780C678B6CFE7CD08 /* BroadcastExtension */;
targetProxy = 3ED5946E30BC57EFC4F6074A /* PBXContainerItemProxy */;
};
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */; target = 97C146ED1CF9000F007C117D /* Runner */;
@ -453,6 +556,32 @@
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
1B44B68DC2E91D78D8E17741 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MACH_O_TYPE = mh_execute;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar.BroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
249021D3217E4FDB00AE95B9 /* Profile */ = { 249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -766,6 +895,57 @@
}; };
name = Release; name = Release;
}; };
B05D86CEA0319AB3711996EF /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MACH_O_TYPE = mh_execute;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar.BroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
B40672BB085BF2C27AB92DBD /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MACH_O_TYPE = mh_execute;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar.BroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -779,6 +959,16 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
8D6F58914ACA89FE06071B10 /* Build configuration list for PBXNativeTarget "BroadcastExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1B44B68DC2E91D78D8E17741 /* Release */,
B40672BB085BF2C27AB92DBD /* Debug */,
B05D86CEA0319AB3711996EF /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

7
apps/client/ios/Runner/Runner.entitlements

@ -10,6 +10,13 @@
<array> <array>
<string>applinks:yimai.games</string> <string>applinks:yimai.games</string>
</array> </array>
<key>com.apple.security.application-groups</key>
<array>
<!-- 多媒体翻译的录屏广播扩展靠它与主 App 通信(Unix socket 建在这个
容器目录下)。改这里必须同时改 BroadcastExtension.entitlements
和苹果开发者后台注册的 App Group,少一处就是静默收不到音频。 -->
<string>group.com.yimai.eaimar</string>
</array>
<key>com.apple.developer.networking.wifi-info</key> <key>com.apple.developer.networking.wifi-info</key>
<true/> <true/>
<key>com.apple.external-accessory.wireless-configuration</key> <key>com.apple.external-accessory.wireless-configuration</key>

40
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 @override
Future<bool> requestScreenCapture() async { Future<bool> requestScreenCapture() async {
if (Platform.isIOS) {
try {
// 顺序不能反:先监听再弹面板。反过来的话用户手快,
// 扩展先起来连不上 socket,要等它 1 秒后重试才接得上。
final bool started = (await _channel
.invokeMethod<bool>('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; if (!Platform.isAndroid) return false;
try { try {
final bool ok = final bool ok =
@ -573,6 +602,17 @@ class AzureAsrService extends GetxService implements AsrService {
@override @override
Future<bool> stopScreenCapture() async { Future<bool> 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; if (!Platform.isAndroid) return true;
try { try {
final bool ok = final bool ok =

45
apps/client/lib/modules/translation/controllers/translation_controller.dart

@ -1197,12 +1197,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _astService.enableRecord(filePath); await _astService.enableRecord(filePath);
} else { } else {
AudioSourceType audioSourceType; AudioSourceType audioSourceType;
if (currentMode.value == 'audioVideo' && Platform.isAndroid) { if (currentMode.value == 'audioVideo') {
audioSourceType = AudioSourceType.systemAudio; audioSourceType = AudioSourceType.systemAudio;
} else { } else {
// ⚠️ iOS 的音视频翻译已经改成手机麦克风收音(见 _configureAudioVideoMode),
// 录音源必须跟着改。留 systemAudio 的话,原生把 index>0 一律当成
// "external",会建好文件然后等一份没人推的 PCM —— 存下来是个空录音。
audioSourceType = AudioSourceType.microphone; audioSourceType = AudioSourceType.microphone;
} }
await _asrService.enableRecord(audioSourceType, filePath, false); await _asrService.enableRecord(audioSourceType, filePath, false);
@ -1422,7 +1419,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// Android 音视频翻译:识别就绪后才弹"录屏"授权 — 用户同意的瞬间 // Android 音视频翻译:识别就绪后才弹"录屏"授权 — 用户同意的瞬间
// 系统音频开始流入已经准备好的 ASR 通路,可以立即切到目标 App。 // 系统音频开始流入已经准备好的 ASR 通路,可以立即切到目标 App。
if (isAndroidAudioVideo) { if (isAndroidAudioVideo || isIosAudioVideo) {
// iOS:起 socket 接收端 + 弹系统「开始广播」面板。
// ⚠️ 返回 true 只代表面板弹出来了,**不代表用户真的开始了广播**——
// 系统没有可查询的回调。所以下面的回滚分支在 iOS 上只会在
// 「接收端都没起起来」(App Group 没配好)时触发。
final captured = await _asrService.requestScreenCapture(); final captured = await _asrService.requestScreenCapture();
if (!captured) { if (!captured) {
// 拒绝则回滚已经启动的识别 + 关掉浮窗,避免空浮窗悬空在桌面 // 拒绝则回滚已经启动的识别 + 关掉浮窗,避免空浮窗悬空在桌面
@ -1538,24 +1539,21 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 让用户立刻就能切到 YouTube 等 App 看翻译结果。 // 让用户立刻就能切到 YouTube 等 App 看翻译结果。
isPreparing.value = false; isPreparing.value = false;
} else { } else {
// ⚠️ iOS 上**没有**「抓取其他 App 音频」这条路。 // iOS 走 ReplayKit 录屏广播扩展拿系统音频(唯一合规途径,iOS 不允许
// 一个 App 读另一个 App 的音频)。真正的启动排在 startRecognition 末尾,
// 与 Android 弹录屏授权的时机一致:先把识别通路准备好,最后才请用户
// 开始广播,同意的瞬间音频就有地方去。
// //
// 这里原来的写法是 openDecoder() + 等 isCodecActive + 音频源置 external, // ⚠️ 这里**不能**再调 openDecoder()。那是杰理时代的写法(音视频翻译 =
// 那是杰理时代的假设:音视频翻译 = 从耳机的 A2DP 解码器拿手机正在放的音乐。 // 从耳机 A2DP 解码器拿手机正在放的音乐),本项目只有恒玄耳机、没这个能力;
// 本项目只有恒玄耳机(没有这个能力),而 openDecoder() 现在是个乐观桩, // 而 openDecoder() 如今是个乐观桩,只把 _isCodecActive 置 true 就返回,
// 它只是把 _isCodecActive 置 true 就返回。于是 iOS 上的实际行为是: // 于是 ASR 以 external 源起来之后**等一份永远没人推的 PCM**——
// ASR 以 external 源启动,然后**等一份永远不会有人推的 PCM**—— // 不报错、不提示,一个字都翻不出来。这正是 iOS 多媒体翻译此前全哑的原因。
// 不报错、不提示,就是一个字都翻不出来。
//
// 退化成手机麦克风:用户外放视频,我们从空气里收。这不是等价替代,
// 音质和抗噪都差一截,但至少功能是通的。要真正拿到系统音频,
// iOS 只有 ReplayKit Broadcast Upload Extension 一条路(需要新建 extension
// target + App Group,且要用户手动发起录屏广播),那是产品决策。
isPreparing.value = false; isPreparing.value = false;
} }
// Android: true = external,PCM 由 MediaProjection 推上来。 // 两端都是 external:PCM 由系统音频捕获推上来
// iOS: false = 麦克风。 // (Android = MediaProjection,iOS = 录屏广播扩展)。
_audioSourceType = Platform.isAndroid; _audioSourceType = true;
isTtsEnabled.value = false; isTtsEnabled.value = false;
Logger.info('发送ble音乐或者通话远端声音'); Logger.info('发送ble音乐或者通话远端声音');
} }
@ -2620,8 +2618,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _teardownBesCallMode(); await _teardownBesCallMode();
} else { } else {
_detachJieliCallTranslationBridge(); _detachJieliCallTranslationBridge();
if (currentMode.value == "audioVideo" && Platform.isAndroid) { if (currentMode.value == "audioVideo" &&
// Android 音视频翻译走 MediaProjection,关掉对应前台服务即可。 (Platform.isAndroid || Platform.isIOS)) {
// Android:关掉 MediaProjection 的前台服务。
// iOS:只关我们这一端的 socket 接收;广播本身得用户从系统面板/灵动岛
// 自己停,App 无权强停(扩展会收到 broadcastFinished 自行退出)。
await _asrService.stopScreenCapture(); await _asrService.stopScreenCapture();
} else { } else {
bleManager.closeCodec(); bleManager.closeCodec();

68
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -1,6 +1,7 @@
import Flutter import Flutter
import UIKit import UIKit
import AVFoundation import AVFoundation
import ReplayKit
import MicrosoftCognitiveServicesSpeech import MicrosoftCognitiveServicesSpeech
// 自定义语音处理组件,提供音频流处理等功能 // 自定义语音处理组件,提供音频流处理等功能
import speech import speech
@ -616,6 +617,73 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
currentAsrCallback = nil currentAsrCallback = nil
result(true) 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": case "pushAudioData":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
let audioBytes = args["data"] as? FlutterStandardTypedData else { let audioBytes = args["data"] as? FlutterStandardTypedData else {

146
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<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
}
}

27
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -187,23 +187,26 @@ public class SimpleAudioReceiver: NSObject {
// 这样耳机以经典蓝牙配对过的话 TTS 走 A2DP;没配过的话回退手机扬声器 // 这样耳机以经典蓝牙配对过的话 TTS 走 A2DP;没配过的话回退手机扬声器
// —— 跟 Android 行为对齐。 // —— 跟 Android 行为对齐。
if audioSourceType == .microphone { // ⚠️ media(多媒体翻译)必须排在 audioSourceType 判断**之前**。
// --- 麦克风输入逻辑 (包括混合模式) ---
if currentRecognitionMode == "media" {
// 多媒体翻译:用户在别的 App 里放视频,我们用麦克风把外放的声音收进来。
// //
// ⚠️ `.mixWithOthers` 是这条路能成立的前提。不加的话,我们一 // 这个模式下音频来自录屏广播扩展(external 源),但会话配置不能用
// `setActive(true)` 就会打断/停掉正在播放的那个 App —— 用户点开 // external 那套 `.playback`:那会打断正在播放的那个 App —— 用户点开
// 翻译,视频就哑了,等于功能自相矛盾。 // 翻译,视频就哑了,功能自相矛盾。`.mixWithOthers` 是这条路能成立的前提。
// //
// ⚠️ mode 用 `.default` 不用 `.voiceChat`:`.voiceChat` 会让 // 用 `.playAndRecord` 而不是纯 `.playback`,是为了让 App 在切到后台
// MicrophoneCapture 打开 setVoiceProcessingEnabled(回声消除), // (用户去看视频)之后仍然活着继续消费 socket 上的 PCM;
// 而这里要收的**恰恰就是本机扬声器放出来的声音**,AEC 会把它当回声 // 配合 Info.plist 的 UIBackgroundModes: audio。
// 精确地消掉,结果就是一片安静、一个字都识别不出来。 //
// ⚠️ mode 用 `.default` 不用 `.voiceChat`:后者会让 MicrophoneCapture
// 打开 setVoiceProcessingEnabled(回声消除)。万一退化到麦克风采集,
// 要收的恰恰是本机扬声器的声音,AEC 会把它当回声精确消掉,一片安静。
if currentRecognitionMode == "media" {
desiredCategory = .playAndRecord desiredCategory = .playAndRecord
desiredMode = .default desiredMode = .default
desiredOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP] desiredOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP]
} else if currentRecognitionMode == "phone_call" { } else if audioSourceType == .microphone {
// --- 麦克风输入逻辑 (包括混合模式) ---
if currentRecognitionMode == "phone_call" {
if isHeadphones { if isHeadphones {
desiredCategory = .playAndRecord desiredCategory = .playAndRecord
desiredMode = .default desiredMode = .default

Loading…
Cancel
Save