You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
101 lines
4.2 KiB
101 lines
4.2 KiB
import Foundation
|
|
|
|
/// 广播扩展 → 主 App 的音频通道(**环回 TCP**,客户端一侧)。
|
|
///
|
|
/// ## 为什么不用 App Group + Unix socket
|
|
/// 那是更常规的做法,但它要求在苹果开发者后台登记 App Group、给主 App 开
|
|
/// App Groups capability,并让 Xcode 能连上 appleid/developerservices2
|
|
/// 重新签发 profile。多一道跨团队的审批,出问题时还完全不在代码里。
|
|
/// 环回 TCP 不需要任何 entitlement:扩展和主 App 在同一台设备上,
|
|
/// 127.0.0.1 天然互通。
|
|
///
|
|
/// ⚠️ **127.0.0.1 不会触发 iOS 的「本地网络」隐私弹窗**——那个弹窗只针对
|
|
/// 局域网访问,环回是明确豁免的。所以也不需要 NSLocalNetworkUsageDescription。
|
|
///
|
|
/// ## ⚠️ 端口写死的取舍
|
|
/// 没有 App Group 就没有地方发布「主 App 这次监听在哪个端口」,所以只能约定
|
|
/// 一个固定端口。代价是理论上会和别的进程撞端口;选了一个冷门高位端口,
|
|
/// 手机上几乎不会有别的东西在环回口监听。撞了的表现是主 App bind 失败,
|
|
/// 日志里会明说,不是静默失败。
|
|
enum BroadcastAudio {
|
|
/// ⚠️ 改这里必须同时改主 App 的 BroadcastAudioReceiver.swift,两边是手工同步的
|
|
static let port: UInt16 = 52781
|
|
}
|
|
|
|
/// 扩展侧的发送端。连接失败会限流重试。
|
|
final class BroadcastAudioSender {
|
|
private var fd: Int32 = -1
|
|
private var lastConnectAttempt: TimeInterval = 0
|
|
|
|
/// 连不上时的重试间隔。
|
|
///
|
|
/// ⚠️ 不能每帧都试:音频帧几十毫秒一个,主 App 没起来时每帧发起一次
|
|
/// connect 会把扩展的 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
|
|
|
|
let s = socket(AF_INET, SOCK_STREAM, 0)
|
|
if s < 0 { return false }
|
|
|
|
var addr = sockaddr_in()
|
|
addr.sin_family = sa_family_t(AF_INET)
|
|
addr.sin_port = BroadcastAudio.port.bigEndian
|
|
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
|
|
|
|
let ok = withUnsafePointer(to: &addr) { p -> Bool in
|
|
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
connect(s, sa, socklen_t(MemoryLayout<sockaddr_in>.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))
|
|
// 音频要的是低延迟,攒包毫无意义
|
|
var nodelay: Int32 = 1
|
|
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &nodelay, socklen_t(MemoryLayout<Int32>.size))
|
|
fd = s
|
|
return true
|
|
}
|
|
|
|
private func disconnect() {
|
|
if fd >= 0 { close(fd) }
|
|
fd = -1
|
|
}
|
|
|
|
/// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。
|
|
///
|
|
/// ⚠️ 长度前缀不能省:TCP 是字节流,**不保留消息边界**,接收端读到的可能
|
|
/// 是半帧、也可能是两帧粘在一起。不分帧的话喂给 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 ok: Bool = payload.withUnsafeBytes { raw -> Bool in
|
|
guard let base = raw.baseAddress else { return false }
|
|
var offset = 0
|
|
while offset < payload.count {
|
|
let n = write(fd, base.advanced(by: offset), payload.count - offset)
|
|
if n <= 0 { return false }
|
|
offset += n
|
|
}
|
|
return true
|
|
}
|
|
if !ok { disconnect() }
|
|
}
|
|
|
|
deinit { disconnect() }
|
|
}
|
|
|