微信
支付宝
# xiaoai-airplay 源码补充:AirPlay 2 优先与新设备接管时踢掉旧连接
这篇作为《从刷机开启 SSH,到自定义 Agent 与 AirPlay:小爱音箱 OH2P 完整记录》的源码补充。前文写了刷机、备份、SSH、部署和使用方法,这里把“AirPlay 接收端到底怎么实现”和“为什么新设备接管后旧设备会真正断开”展开到代码层。
源码仓库:[hitushen/xiaoai-airplay](https://github.com/hitushen/xiaoai-airplay)
## 1. 运行时优先 AirPlay 2
应用依赖显式启用 shairplay 的 `ap2` feature:
```toml
shairplay = { path = "vendor/shairplay", default-features = false, features = ["ap2"] }
```
配置文件默认也是:
```yaml
mode: ap2
```
启动时在 `src/main.rs` 中把配置映射为 `AirPlayMode::AirPlay2`:
```rust
builder = builder.mode(match config.mode.to_ascii_lowercase().as_str() {
"ap1" => AirPlayMode::AirPlay1,
_ => AirPlayMode::AirPlay2,
});
```
因此正常部署首先按 AirPlay 2 广播和协商;如果需要兼容只支持传统 RAOP 的发送端,可以把配置切换为 `mode: ap1`。底层仍使用同一个 RTSP listener,AP1 的 handler 和 AP2 的 handler 都保留在 vendored `shairplay` 中。
## 2. 音频输出没有绕过小爱原生灯效
AirPlay 收到并解码后的 PCM 交给应用自己的 `AudioHandler`,再写给:
```text
aplay -D default
↓
default → vis → ledd → Playback / dmixer → hw:0,2
```
代码中没有把设备硬编码为 `hw:0,2`,而是使用配置中的 `device: default`。这样 AirPlay 播放时仍然经过小爱原生可视化链路,音乐灯条会呼吸;语音抢音时只对 AirPlay PCM 做 ducking,不改原生 TTS 音量。
## 3. 旧版本为什么会“还显示连接但没有声音”
之前只停止旧的音频任务,旧设备的 RTSP 控制连接仍然留在服务端。发送端还以为自己连接着音箱,但音频已经不再被写入,所以表现为“连接还在、没有声音”。
这次修改分成两层:
1. 停止旧的音频输出任务;
2. 关闭旧发送端的所有 RTSP TCP 控制连接。
只做第一层是不够的。
## 4. `net/server.rs`:给每个 TCP 连接增加关闭信号
每个 RTSP 连接创建一个 `watch` 通道,并把接收端交给连接处理循环:
```rust
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let connection = ConnectionHandle::new(peer_addr, shutdown_tx);
process_connection(stream, handler, shutdown_rx).await;
```
处理请求时不再只等待 TCP 数据,而是同时等待网络读取和接管通知:
```rust
tokio::select! {
result = stream.read_buf(&mut buffer) => {
// 正常处理 RTSP 请求
}
changed = shutdown_rx.changed() => {
if changed.is_ok() && *shutdown_rx.borrow() {
// 旧发送端被新设备接管,退出连接任务
break;
}
}
}
```
连接任务退出后 `TcpStream` 被 Drop,旧设备的 RTSP 控制连接才会真正关闭。
## 5. `raop/connection.rs`:按发送端 IP 做接管
连接登记表按发送端 IP 保存当前 RTSP 连接。新设备完成有效音频协商时,调用 `takeover_sender()`:
```rust
pub(crate) fn takeover_sender(&self, sender_ip: IpAddr) {
let mut connections = self.connections.lock().unwrap();
for (ip, handles) in connections.iter() {
if *ip != sender_ip {
for handle in handles {
handle.shutdown();
}
}
}
}
```
这里故意保留同一个发送端 IP 的并行连接。iPhone 在 AirPlay 2 协商期间可能同时建立多条 RTSP 连接,如果把当前发送端也一起踢掉,会破坏 Happy Eyeballs/并行协商。
对应测试:
```text
takeover_preserves_parallel_current_sender_connections ... ok
```
## 6. AP1 与 AP2 在什么时机触发接管
AP1 在完成 RTP `SETUP`、已经具备音频输出条件后触发接管:
```rust
// handlers_ap1.rs
connection.takeover_sender(sender_ip);
```
AP2 则在 realtime 或 buffered audio 建立后触发:
```rust
// handlers_ap2.rs
connection.set_active_audio(new_audio);
connection.takeover_sender(sender_ip);
```
这样不会在仅仅建立控制连接、尚未真正开始播放时误踢已有播放;只有新发送端已经完成音频通道建立,才接管旧设备。
## 7. 测试和真实部署
源码测试覆盖:
- 空闲 TCP 连接可以被关闭通知中断;
- 新发送端会踢掉旧发送端的 RTSP 连接;
- 当前发送端的并行连接不会被误踢;
- 应用层 session owner 仍然只允许最新音频输出;
- AP2 pairing、realtime/buffered audio、AP1 传统路径均通过测试。
本次 OH2P 部署时,先保留原线上备份,再使用针对旧 glibc 构建的 ARMv7 二进制。音箱启动日志确认:
```text
AirPlay receiver started ... mode=ap2 ...
PTP sink active on 319/320
mDNS: _raop._tcp registered ... port=5000
mDNS: _airplay._tcp registered ... port=5000
```
如果需要回滚,只需停止 AirPlay,把 `/data/open-xiaoai/xiaoai-airplay` 替换回带时间戳的 `.bak-*` 文件,再用原来的 `start-airplay.sh start` 启动;不会改动原生语音服务。
## 8. 当前实际选择
当前方案不是 AirConnect 转发,也不是只杀 `aplay` 的静音方案,而是:
```text
iPhone / Mac
↓ AirPlay 2 优先,AP1 兼容
xiaoai-airplay / vendored shairplay
↓ 新设备完成音频 SETUP 后接管
关闭旧 RTSP 连接 + 停止旧音频任务
↓
aplay -D default
↓
小爱原生 vis/ledd 灯效链路
```
刷机和 SSH 过程见主文章;源码、测试和构建说明见仓库。
BLOG | Mortal
探索 | 实践 | 记录 | 分享
本文是原创文章,采用 CC BY-NC-ND 4.0 协议,完整转载请注明来自 途深