把 AirPlay 镜像跑上 ESP32-P4:一次音视频、内存和协议边界的工程复盘

ESP32-P4 AirPlay 工程封面

这次探索的目标很直接:在 ESP32-P4 Function EV Board v1.4 上跑一个 AirPlay 镜像接收端,保留 ESP-UI 原来的桌面体验,同时补上 Wi-Fi、RTSP/RTP、H.264、RAOP 音频、状态面板和重连处理。

代码已开源:novono/ESP32-P4-AirPlay

硬件基线是 ESP32-P4 主控、7 英寸 1024 x 600 MIPI-DSI 屏、ES8311 codec,以及板载 ESP32-C6-MINI-1 提供的 2.4 GHz Wi-Fi 6 / BLE 通信能力。软件基线是 ESP-IDF 6.2、官方 BSP、ESP-UI 和 LVGL 8.4。

当前固件启动后仍然进入 ESP-UI 桌面。AirPlay 是其中一个应用入口,服务也可以在 Wi-Fi 连通后后台广播。进入 AirPlay App 后,可以查看状态、启动、停止,并预览镜像画面。

硬件和范围

ESP32-P4 面向 HMI、多媒体和高速外设场景,有双核 RISC-V、MIPI-DSI 显示路径、PSRAM、PPA 等资源。开发板接 7 英寸 1024 x 600 屏,显示走 EK79007 RGB565 路径,触摸、音频和外设初始化沿用官方 BSP。

AirPlay 镜像接收主要拆成三条链路:网络侧做 mDNS 广播、RTSP 控制、RTP 数据接收、配对和 FairPlay 兼容;视频侧处理 H.264 镜像流;音频侧处理 RAOP、加密、乱序、重传和 AAC-ELD 解码。

ESP32-P4 这里没有直接可用的 AirPlay 接收端硬件 H.264 解码路径。视频解码使用迁移来的 OpenH264 软件路径,后面再做显示搬运、缩放、转色和帧缓存管理。

架构边界先立住

系统架构图

我在项目里首先收紧的是模块边界。components/mirror_airplay 是 AirPlay 服务边界,向外暴露 init/start/stop/set_name/snapshot 这类 API。UI 不直接调用底层 esp_wifi_*,也不把历史工程里的 S31 UI 逻辑搬进来。

AirPlay 服务内部状态很多:RTSP session、mirror socket、timing socket、audio socket、H.264 队列、AAC decoder、RTP reorder buffer、重连清理、状态统计。把这些都收在服务层里,UI 只拿 snapshot 和控制接口。

Wi-Fi 也做了类似收口。mirror_wifi 统一管理扫描、连接、NVS 保存和状态快照。在当前实现里,它实际委托到 mirror_airplay_wifi_connect()mirror_airplay_wifi_clear(),但对 Settings 和 AirPlay App 来说,Wi-Fi 仍然是一个明确的服务接口。

这让 AirPlay App 可以保持很薄。它只负责入口、控制按钮、状态面板和视频预览。服务本身可以在后台继续广播和连接;用户退出 AirPlay App,也不会把协议服务一起杀掉。

最难的不是画面出来,而是稳定地出来

视频链路图

视频链路的表面目标很简单:把手机或 macOS 的镜像画面显示到 1024 x 600 屏上。实际拆开以后,关键点很多。

第一层是协议。AirPlay 镜像流要经过 RTSP setup,拿到镜像端口、加密参数和视频格式信息。数据进来后,需要做 AES-CTR 解密,再把 H.264 NAL 从客户端格式适配成 OpenH264 更容易消费的 Annex-B 形式。

第二层是解码。OpenH264 是软件解码器,ESP32-P4 又是 MCU,不是桌面 CPU。项目里没有假装自己能稳定吃下任意分辨率,而是在 Kconfig 里把最大输入和目标输出收住。当前默认最大接受 720 x 360,目标渲染 480 x 270,目标帧率 6 fps,H.264 队列深度为 4。

这几个数字不是为了好看,而是为了把系统放在能观察、能调、能恢复的范围内。README 里也明确写了,如果板上测试出现 watchdog reset、PSRAM 压力或解码超时,就继续向下调。

第三层是恢复。项目里记录 keyframe 状态,遇到解码错误、启动无帧、画面卡住或恢复期非 IDR 帧,会主动请求关键帧,并在必要时触发重连,避免一直卡在坏流状态。

第四层是显示。解码后的画面走 I420/RGB565,再交给 LVGL 预览。AirPlay App 里根据容器大小计算 zoom,使用 contain/letterbox 策略尽量利用屏幕,同时避免拉伸变形。mirror_airplay_render 则负责最新帧缓存、64 字节对齐、PSRAM 分配、序列号和绘制统计。

音频链路的坑更隐蔽

音频链路图

视频卡顿很容易被看见,音频问题更隐蔽。断续、晚到、重复包、重传失败、解码错误、I2S 写入超时,任何一个点都可能让体验变差,而且串口日志里很难一眼看出根因。

项目里的 RAOP 音频链路做了几件关键事情。UDP 数据包进入后,先解析 RTP header,完成 AES-CBC 解密,然后进入固定深度的 reorder buffer。发现缺包时,会记录 missing sequence,并按节流策略发送 resend request。对于晚到包、重复包、gap、overrun,也都进入状态统计。

解码侧使用本地 components/fdk_aac 子集处理 AAC-ELD,输出 44.1 kHz stereo PCM,再通过 bsp_extra_codec_set_fs()bsp_extra_i2s_write() 送到 ES8311 codec。这里有一个很实际的工程判断:先把 AAC-ELD 这条 AirPlay 镜像常用链路跑通,而不是把 ALAC、普通 AAC、播放器控制等范围一起摊开。

AirPlay 的 native video URL playback、protected media reception 明确不支持。/play/rate/scrub/playback-info 等请求返回不支持。当前范围只覆盖镜像和 RAOP 音频。

性能和内存取舍

当前 sdkconfig.defaults 做了几类基础优化:PSRAM 200 MHz、PSRAM XIP、允许任务栈使用外部内存、L2 cache 256 KB、cache line 128 B、FreeRTOS tick 1000 Hz、LVGL 使用自定义内存。分区也按 16 MB flash 做了自定义布局:factory app 9 MB,SPIFFS 4 MB。

PPA 没有默认打开。CONFIG_MIRROR_AIRPLAY_USE_PPA 在 Kconfig 里标成 experimental:软件 I420 到 RGB565 是默认路径,PPA 保留为优化钩子,需要先验证具体 YUV420 输入布局。

这套配置先保证默认路径可诊断、可回退。硬件加速相关内容放在独立开关里,后续可以在数据布局、颜色范围、旋转和缓存一致性确认后再打开。

风险控制:让系统说实话

调试仪表盘示意图

这个项目的另一个重点是可观测性。mirror_airplay_status_t 很大,里面不是只有 startedconnected。它记录了 RTSP sessions、session resets、reconnects、stop timeouts、H.264 bytes、视频包、音频包、重传、解密错误、buffer ready、PCM 输出、decoder errors、heap free、PSRAM free、decode fps、转换耗时、绘制耗时等状态。

AirPlay App 的状态面板会把这些关键数据压缩显示出来。服务侧还提供 /debug/status,可以用 nc 直接查询。这一点非常实用,因为调试投屏时,很多问题并不发生在 UI 线程里。你需要知道到底是 Wi-Fi 断了、RTSP session 重置了、音频包乱序了、H.264 等关键帧,还是解码超过预算。

重连也做了明确清理。断开或客户端重连时,会停止 mirror/audio/timing 任务,等待旧任务退出,再释放大帧、解码器和 RTP buffer。这个顺序很关键。很多嵌入式崩溃不是因为功能没写完,而是因为任务还没停干净,另一个路径就释放了它还在访问的内存。

项目里还加了 mirror_airplay_selftest_run(),覆盖 AVCC 到 Annex-B 的基础转换、IDR/非 IDR 判断、非法长度、像素预算和音频 sequence wraparound。它不是完整测试体系,但它保护了几类最容易被后续改动破坏的基础假设。

发布风险不能最后才想

这个项目还有一类风险和代码无关,但和工程交付强相关:授权和分发。

RELEASE.md 里明确要求发布前检查本地 sdkconfig、Wi-Fi 凭据、串口端口、抓包、证书、私钥、.env 和构建产物,避免把环境信息带进仓库。AirPlay pairing keys 在设备运行时创建和存储,不应该作为默认配置出现。

依赖侧也写得很清楚。components/playfair 缺少独立 license notice,并包含 legacy FairPlay compatibility tables;ed25519_donna 需要确认上游授权归属;OpenH264 要看 Cisco OpenH264 obligations;FDK-AAC 要保留 NOTICEMODULE_LICENSE_FRAUNHOFER,同时对 AAC patent/licensing 做单独审查。

README 把当前范围限定为开发板实验和工程验证。技术跑通以后,分发前仍然需要单独处理授权、专利和密钥检查。

最终成效

这个项目目前已经把 ESP-UI 桌面保留下来,并把 AirPlay 做成了一个清晰的应用入口。Wi-Fi 连通后,AirPlay 可以自动启动广播;用户也可以在 App 内启动、停止和查看状态。视频侧支持 AirPlay 镜像流,音频侧支持 RAOP、RTP reorder/resend、AAC-ELD 解码和 PCM 输出到 ES8311。

构建链路也已经落地。README_MIRROR 里记录的构建产物包括 build/bootloader/bootloader.binbuild/partition_table/partition-table.binbuild/mirror.binbuild/storage.bin。工程使用 16 MB flash、自定义 partition table、9 MB app 和 4 MB SPIFFS,符合当前 UI 与音视频资源需求。

原生 AirPlay video URL playback 和 protected media reception 不在范围内;PPA 是实验优化;默认视频目标不是追求满屏高帧率;发布前需要专门做授权和密钥检查。

后续继续推进时,可以优先做三件事:第一,基于真实设备日志建立一组性能基线,包括不同分辨率下的解码耗时、丢帧和 PSRAM 余量;第二,在明确输入布局后验证 PPA 路径是否进入默认配置;第三,把授权风险和可分发范围拆成单独 checklist。

这次验证的重点不是把所有能力一次做满,而是先把镜像、音频、状态、恢复和风险边界跑通。断开、重连、慢帧、丢包、内存紧张时,系统要能看到状态,也要有下一步处理路径。

在资源受限的系统里,复杂功能需要边界、状态和恢复路径支撑。这个版本先把这些基础打出来,后续再围绕性能和兼容性继续收敛。