# 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 过程见主文章;源码、测试和构建说明见仓库。