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.
117 lines
4.8 KiB
117 lines
4.8 KiB
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() }
|
|
}
|
|
|