# aes67-ESP32-P4 AES67 sender on a Waveshare ESP32-P4-ETH (PoE). Sources: Spotify Connect (cspot) and HLS/m3u8, with failover. Riedel-style web UI and a reusable AES67 core (PTP, RTP TX, SDP/SAP, VLAN, syslog, health) meant for later AES67 projects. Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 ## Read first - `docs/aes67-core-base.md`: reusable core. Config schema, API, PTP (roles, Riedel defaults, status fields), AES67 TX/SDP/SAP, VLAN, syslog, temperatures, component layout. - `docs/hardware-and-design-notes.md`: board pinout, chip revision caveat, project pipeline (cspot/HLS), failover, player API. - `web/index.html`: finished web UI (single file). It is the API contract; firmware must match the JSON it reads and writes. ## Working rules - **One change at a time** ("one fuckup at a time"). Change or test one variable per step; never stack several fixes or hypotheses. If something breaks, go back to the last known-good state. - Follow the build order below. Finish and verify a step on hardware before starting the next. - After every change: `idf.py build`, flash, and check the serial monitor output. Show the relevant log lines. - Don't mark a step done until it has been verified on the board. - Keep core and project separate: `components/aes67_*` must not depend on `main/` (sources/player). Project code plugs in through the config, status and route registries. - Licences: core (`components/aes67_*`) and `web/` are MIT, the rest GPL-3.0-or-later (see THIRD_PARTY.md). Never pull GPL code (cspot, bell, squeezelite) into the core. New third-party code: check its licence first and add it to THIRD_PARTY.md. - `web/index.html`: keep the CORE / PROJECT markers. If the firmware needs an API change, change the doc and the page together. - Small, focused commits per step. ## Toolchain - ESP-IDF 5.5 or later (needed for the P4 and for `examples/network/vlan_support`). Target `esp32p4`. - `idf.py set-target esp32p4`, `idf.py build`, `idf.py -p flash monitor` - Before first build: run `esptool.py chip_id` and `esptool.py flash_id`. - Chip revision < v3.0 (engineering silicon, seen on some of these boards) needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y`. Our board: **v1.3** (set in sdkconfig.defaults, min rev v1.0). - Our board: **32 MB** flash (GigaDevice c8/4019). App slots must stay below 16 MB (cache mapping above 16 MB is experimental in IDF). - Rev < 3 also limits Espressif's prebuilt audio libraries: `esp_audio_codec` must stay < 2.6 and `esp_audio_effects` < 1.4 (newer versions use P4 assembly that needs rev >= 3; the build fails with a message saying so). Check this for any new Espressif binary component. - Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`. - cspot (Spotify, `external/cspot` git submodule, philippe44 fork, pinned): after cloning run `git submodule update --init external/cspot && git -C external/cspot submodule update --init cspot/bell`. Its nanopb code generator needs, in the IDF Python env: `python -m pip install protobuf grpcio-tools 'setuptools<81'` (after `. export.sh`). Our fixes to cspot/bell live in `components/spotify/patches/{cspot,bell}/*.patch` and are applied automatically at configure time; don't edit the submodule directly, add a patch. - Firmware version: `-`, e.g. `0.0.1-1d613b1`. Bump `version.txt` for a release; the hash is the commit that was built (commit first, then build, so it matches). No dirty flag. - Flash over the network (normal way since step 2b; keep USB for recovery): `curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota` The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`. - Another board, over USB: `tools/flash_board.py` (checks chip revision and flash size, optional erase, flashes, finds the IP in the boot log, sets hostname / stream name / multicast so boards don't clash). Needs a build and esptool (IDF env or `pip install esptool`). - Serial: opening /dev/ttyACM0 can reset the board. Don't open it while an OTA image is on trial (a reset then counts as a failed boot and rolls back). - Rollback test build (self-test always fails), in its own build dir: `idf.py -B build-selftest-fail -DSDKCONFIG=build-selftest-fail/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.selftest_fail" build` ## Board quick reference (full details in docs) - Ethernet: IP101GRI, RMII, PHY addr 1, ref clock from PHY into GPIO50 (EMAC_CLK_EXT_IN). MDC 31, MDIO 52, PHY reset/power 51, TX_EN 49, TXD0 34, TXD1 35, CRS_DV 28, RXD0 29, RXD1 30. - No Wi-Fi/BT on this board. - On-die temperature sensor only (driver/temperature_sensor.h). ## Build order / status - [x] 0. Check chip revision and flash size; create IDF project and empty component stubs (layout in aes67-core-base.md). Set up the OTA partition table (two app slots, no factory) and PROJECT_VER now, so the layout never changes later. (PROJECT_VER = `version.txt` + git short hash.) - [x] 1. Ethernet: IP101 up, DHCP, IP logged. Ping works. - [x] 2. Web server + config store (cJSON in NVS) + embedded index.html; /api/config, /api/status (stub values), /api/reboot. - [x] 2a. Finding the device: mDNS (hostname.local + _http._tcp), then LLDP (switch shows name + IP). - [x] 2b. Firmware update: /api/ota upload + rollback self-test. Test: update to a new build, then deliberately flash a build that fails its self-test and confirm it rolls back. After this, flash over the network; keep USB for recovery. - [x] 3. PTP TimeReceiver: lock to an existing GM (Riedel), fill `status.ptp`. Confirm EMAC hardware timestamps work. First check whether the installed ESP-IDF has a PTP example/component for the P4 before writing one. - [x] 4. AES67 TX with a 1 kHz test tone, PTP-paced; /stream.sdp. Verified with a receiver script (all ptimes, L16/L24, tone phase-locked to PTP) and on a Riedel Artist 4-wire AES67 port. - [x] 5. SAP discovery, then syslog, then health/temperatures (one at a time). VLAN split moved to phase 2. - [x] 6. PTP TimeTransmitter: BMCA roles (slave/auto; the separate "master" role was dropped later, auto with a low priority1 does the same), hybrid mode. - [ ] 7. Sources: HLS player, then cspot (Spotify Connect), then failover + /api/player. - [x] 7.1-7.4 HLS plays on AES67 (PSRAM ring, TS demux + AAC, 44.1 -> 48 kHz). Tested with Triple J Hottest (TS, AAC-LC 44.1k). - [ ] Come back to HLS (open items): - Clock drift: the station's encoder clock vs our PTP clock is not corrected. Starts 3 segments (~30 s) behind live, so it shows after hours/days (skip when falling out of the live window, or buffering). Fix: steer the 44.1 -> 48 kHz ratio by a few ppm from the distance to the live edge / ring level. - Download speed ~1.4 Mbit/s over TLS (fine for ~250 kbit/s; tune buffer sizes / per-chunk overhead). - Not yet tested: HE-AAC variant (140k), other stations, fMP4/ADTS-only playlists, discontinuities (#EXT-X-DISCONTINUITY), network loss and recovery, long runs. - Audio starts only after PTP lock (~20 s after boot): intended, TX needs PTP. - [ ] cspot (Spotify Connect): login, audio, pause/skip/seek, app volume, live mode switching work; stays connected across track changes and past 6 min (7.5b). Fixed so far: logger NULL crash, Vorbis symbol clash, volume starting at 0, reconnect race (PR #3 patch), 6-minute AP resets (delayed Pong patch), app dropping the device (notifyAudioReachedPlayback). Open: - OTA with a session: now ends the session first; 2 of 2 uploads with a running session were clean since. Flash writes still stall TX > 20 ms during uploads (stream stutters while updating). - First connects sometimes fail ("Can't connect to spotify servers"), a retry works. - Internal heap with a session: low point while playing was 212 KB (of ~371 KB idle), now ~300 KB with mbedTLS and Tremor (Vorbis) allocations in PSRAM; playback clean, 0 underruns. Left: ~35 KB connected + ~40 KB playing; the session keeps ~35 KB internal / ~190 KB PSRAM after the app disconnects. A status request once took > 2 s at playback start (TLS handshake in PSRAM?), watch. - Seen once: state `error` before a connect that then worked (the first-connect item below?). - Track display in the app can switch ~3-5 s early. - Consider offering the delayed-Pong fix upstream (philippe44/cspot). - An old, long-idle session can get out of sync with the app: it sends empty Load frames ("No tracks in frame") instead of Pause/Play, so controls do nothing. Quitting and reopening Spotify on the Mac fixes it. - [x] failover (auto mode): no session or (with `failover_on_pause`) paused for `failover_delay_s` -> HLS; Spotify playing -> Spotify within ~1 s; 30 ms fades; HLS suspended while Spotify plays; cspot is held back (not drained) while it isn't Spotify's turn, so it resumes where it paused. Verified: no session -> HLS, play -> Spotify, pause stays (on_pause off), pause -> HLS after 5 s (on_pause on), play -> Spotify at the paused position. - Open: `aes67_tx resync` (TX 20-27 ms late) ~11 s after each switch to Spotify (seen 3 times); clicks at switches not yet checked in a recording; HLS start sometimes hits CDN read timeouts. - [x] /api/player: GET, transport, seek, volume (the app follows), source override and url (runtime, not saved); web UI progress bar. - [x] 8. Mono sum, gain, polish. - [x] Mono sum: stereo source mapped to a 1-channel stream in the core ((L+R)/2, 64 bit). Checked: 156 B packets, SDP L24/48000/1. - [x] Gain: source.gain_db as a trim on top of the volume, saturating. Checked: -12 dB = -12 dB peak, +12 dB clips at 0 dBFS. - [x] Pink noise source (-18 dBFS RMS, octaves flat within 0.4 dB 63 Hz-4 kHz). - [x] 48 kHz only (96 kHz dropped: all sources are 48 kHz). - [x] Network: status mask/gw/dns, UI shows the lease; static IP (reboot on change, validation). - [x] Web UI: sticky save bar, dark default + light toggle, progress bar, PTP panel (2 min average offset, details), uptime d/h/m/s, VLAN option hidden, no hw_ts / preferred-TimeTransmitter options. - [x] Versioning `-`; tools/flash_board.py (tested on this board); docs/user-guide.md. ## Phase 2 (parked) - [ ] Replace the Espressif binary audio libraries (GPL-3 conflict, see THIRD_PARTY.md): AAC via opencore-aacdec (Apache-2.0, in bell/external), 44.1 -> 48 kHz via the Speex resampler (BSD). Do together with / right before the clock-drift correction (Speex can steer the ratio). - [ ] Clock-drift correction (do before AirPlay): steer the 44.1 -> 48 kHz converter by a few ppm from the ring level / distance to the live edge, so push sources that run on the sender's clock don't slowly over- or underrun. Closes the HLS clock-drift item in step 7; AirPlay needs the same. - [ ] AirPlay 1 (RAOP) as a source: `_raop._tcp` via our mDNS, RTSP control, AES (mbedTLS), ALAC decode, RTP audio + timing, 44.1 kHz into the existing converter, uses the clock-drift correction. Start from philippe44's RAOP code in squeezelite-esp32 (`components/raop`, checked 2026-09-27 at commit 1d542bd): - Take `raop.c`, `util.c` (MIT, philippe44) and `rtp.c` (MIT, James Laird/shairport); keep their headers. Write our own glue to the player (their `raop_sink.c` has no licence header and the repo has no LICENSE file; it's specific to squeezelite anyway). - ALAC: they link a prebuilt `libalac.a`; build Apple's ALAC from source instead (Apache-2.0, already in `external/cspot/cspot/bell/external/alac`). - `dmap_parser.c` (track metadata) has no header: confirm its origin (likely the MIT dmap-parser project) or leave it out. - `raop.c` contains the AirPort Express RSA private key (reverse-engineered in 2011, in every AirPlay 1 receiver): known grey area. - Code is written for squeezelite-esp32's platform (pthreads, their logging, OpenSSL/mbedTLS switches, NVS): needs porting like cspot. `rtp.c` hands each frame to the sink with its play time (NTP-synced to the sender); following the sender's clock is the sink's job, i.e. our clock-drift correction. Covers iPhone/iPad/Mac (incl. Tidal and Apple Music apps) and Windows via AirPlay senders (TuneBlade, Airfoil). Not AirPlay 2 (HomeKit pairing, its own PTP on ports 319/320 clashes with ours). Then: auto mode as a priority list (whichever source plays wins, HLS as fallback). Check internal heap with Spotify + AirPlay sessions (buffers to PSRAM). - [ ] VLAN split: AES67 untagged + internet tagged (inet.vlan_id/pcp), per aes67-core-base.md "Network". Needs a tagged VLAN with DHCP on the switch port. Config group `inet` and the UI fields exist already; nothing is applied yet. The "VLAN split" checkbox is hidden in web/index.html (`