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.
137 lines
5.9 KiB
137 lines
5.9 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。没连上过就断开不算「主 App 收工了」——
|
|
/// 那是用户先开广播、还没点开始翻译的正常情况。
|
|
private var everConnected = false
|
|
|
|
/// 从「曾经连上」变成断开的时刻
|
|
private var disconnectedAt: TimeInterval?
|
|
|
|
/// 断开多久之后判定主 App 已经收工。
|
|
///
|
|
/// ⚠️ 不能设太短:主 App 短暂卡顿或切换时可能瞬断,误判会把用户正在用的
|
|
/// 广播直接掐掉。也不能太长,否则点了「结束」还要等半天屏幕上那条红条才消失。
|
|
private let peerGoneGrace: TimeInterval = 3.0
|
|
|
|
/// 判定主 App 已收工时回调一次(由 SampleHandler 用来结束广播)
|
|
var onPeerGone: (() -> Void)?
|
|
private var peerGoneFired = false
|
|
|
|
/// 供外部定时轮询:即使当前没有音频帧进来,也能发现主 App 已经走了。
|
|
func checkPeerAlive() {
|
|
_ = ensureConnected()
|
|
}
|
|
|
|
/// 连不上时的重试间隔。
|
|
///
|
|
/// ⚠️ 不能每帧都试:音频帧几十毫秒一个,主 App 没起来时每帧发起一次
|
|
/// connect 会把扩展的 CPU 打满,而广播扩展的内存上限只有 **50MB**,
|
|
/// 超了系统直接杀进程(表现是广播莫名其妙就停了)。
|
|
private let retryInterval: TimeInterval = 1.0
|
|
|
|
private func ensureConnected() -> Bool {
|
|
if fd >= 0 { return true }
|
|
let now = Date().timeIntervalSince1970
|
|
// 连过一次又断了、且超过宽限期还连不回来 → 主 App 已经收工(用户点了
|
|
// 「结束」,那边把 socket 关了)。广播只能由扩展自己终止,主 App 对
|
|
// 系统面板发起的广播没有任何控制权,所以这个判断是唯一的收尾时机。
|
|
if everConnected, let since = disconnectedAt,
|
|
now - since > peerGoneGrace, !peerGoneFired {
|
|
peerGoneFired = true
|
|
onPeerGone?()
|
|
return false
|
|
}
|
|
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
|
|
everConnected = true
|
|
disconnectedAt = nil
|
|
return true
|
|
}
|
|
|
|
private func disconnect() {
|
|
if fd >= 0 { close(fd) }
|
|
fd = -1
|
|
if everConnected && disconnectedAt == nil {
|
|
disconnectedAt = Date().timeIntervalSince1970
|
|
}
|
|
}
|
|
|
|
/// 发一帧 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() }
|
|
}
|
|
|