Переглянути джерело

Scaffold ESP-IDF project and vendor AES67 base

Set up the buildable project skeleton for the ESP32-P4 Dante/AES67
endpoint (phase 1: AES67 interop):

- Fix gitignore -> .gitignore so the ignore rules (reference/ GPL
  boundary, build artifacts) are actually active.
- Vendor components/aes67 from DatanoiseTV/aes67-esp32p4 (Apache-2.0)
  as plain tracked files; NOTICE preserved.
- Add top-level + main CMakeLists, idf_component.yml (esp-dsp, es8311),
  and the tuned sdkconfig.defaults from upstream.
- Adapt the upstream example into main/main.c (SPDX-attributed), with
  all pins routed through a single main/board_config.h.
- README: update status/roadmap and flag the inconsistent I2S pin table
  (board_config.h is a placeholder pending hardware confirmation).

Not build-verified (no ESP-IDF toolchain in this environment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benjamin Harris 3 місяців тому
батько
коміт
55b0d95b52
49 змінених файлів з 13232 додано та 4 видалено
  1. 0 0
      .gitignore
  2. 7 0
      CMakeLists.txt
  3. 23 4
      README.md
  4. 57 0
      components/aes67/CMakeLists.txt
  5. 204 0
      components/aes67/Kconfig
  6. 239 0
      components/aes67/Kconfig.projbuild
  7. 20 0
      components/aes67/NOTICE
  8. 113 0
      components/aes67/README.md
  9. 22 0
      components/aes67/idf_component.yml
  10. 131 0
      components/aes67/include/aes67.h
  11. 177 0
      components/aes67/include/aes67_audio.h
  12. 233 0
      components/aes67/include/aes67_config.h
  13. 82 0
      components/aes67/include/aes67_convert.h
  14. 38 0
      components/aes67/include/aes67_l2tap_rx.h
  15. 95 0
      components/aes67/include/aes67_net.h
  16. 103 0
      components/aes67/include/aes67_ptp.h
  17. 187 0
      components/aes67/include/aes67_rtp.h
  18. 99 0
      components/aes67/include/aes67_sap.h
  19. 104 0
      components/aes67/include/aes67_sdp.h
  20. 140 0
      components/aes67/include/aes67_session.h
  21. 50 0
      components/aes67/include/aes67_webui.h
  22. 53 0
      components/aes67/private_include/aes67_hw_timer.h
  23. 662 0
      components/aes67/src/aes67.c
  24. 990 0
      components/aes67/src/aes67_audio.c
  25. 175 0
      components/aes67/src/aes67_convert.c
  26. 144 0
      components/aes67/src/aes67_hw_timer.c
  27. 361 0
      components/aes67/src/aes67_l2tap_rx.c
  28. 87 0
      components/aes67/src/aes67_mdns.c
  29. 188 0
      components/aes67/src/aes67_net.c
  30. 507 0
      components/aes67/src/aes67_ptp.c
  31. 1171 0
      components/aes67/src/aes67_rtp.c
  32. 599 0
      components/aes67/src/aes67_sap.c
  33. 412 0
      components/aes67/src/aes67_sdp.c
  34. 611 0
      components/aes67/src/aes67_session.c
  35. 1028 0
      components/aes67/src/aes67_webui.c
  36. 3 0
      components/aes67/third_party/esp_eth_time/README.md
  37. 142 0
      components/aes67/third_party/esp_eth_time/esp_eth_time.c
  38. 131 0
      components/aes67/third_party/esp_eth_time/esp_eth_time.h
  39. 3 0
      components/aes67/third_party/ptpd/README.md
  40. 188 0
      components/aes67/third_party/ptpd/include/ptpd.h
  41. 2437 0
      components/aes67/third_party/ptpd/ptpd.c
  42. 143 0
      components/aes67/third_party/ptpd/ptpv2.h
  43. 262 0
      components/aes67/web/index.html
  44. BIN
      components/aes67/web/index.html.gz
  45. 6 0
      main/CMakeLists.txt
  46. 39 0
      main/board_config.h
  47. 7 0
      main/idf_component.yml
  48. 660 0
      main/main.c
  49. 99 0
      sdkconfig.defaults

+ 0 - 0
gitignore → .gitignore


+ 7 - 0
CMakeLists.txt

@@ -0,0 +1,7 @@
+# Top-level ESP-IDF project file for the ESP32-P4 Dante/AES67 endpoint.
+# Requires ESP-IDF v5.5+ with the esp32p4 target.
+cmake_minimum_required(VERSION 3.16)
+
+include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+
+project(esp32p4_dante)

+ 23 - 4
README.md

@@ -1,6 +1,9 @@
 # Dante / AES67 Audio-over-IP for ESP32-P4
 
-**Status: planning / architecture phase — no firmware written yet.**
+**Status: AES67 base vendored in, untested on hardware.** The `aes67-esp32p4`
+engine and a board-configurable app entry point are in place; not yet
+build-verified or run against real gear. Native Dante support is still at the
+notes stage.
 
 ## Goal
 
@@ -68,6 +71,17 @@ board — confirm/update against whatever board this actually gets deployed on:
 
 Ethernet PHY: IP101 GRI via RMII. Onboard codec: ES8311 + NS4150B amp.
 
+> **Unresolved — confirm against the actual board.** The I2S rows above are
+> not internally consistent. BCLK/LRCLK/DOUT (36/33/32) are the *external
+> DAC* pins from the upstream example, while DIN (11) is the *onboard ES8311*
+> ADC pin — the upstream example's active config actually uses DIN=21 for an
+> external ADC. Decide whether this build targets the **onboard ES8311**
+> (MCLK 13, BCLK 12, LRCLK 10, DOUT 9, DIN 11) or an **external DAC/ADC**
+> (MCLK 13, BCLK 36, LRCLK 33, DOUT 32, DIN 21), then make the table,
+> `main/board_config.h`, and the ES8311 vs. external assumption in
+> `main/main.c` all agree. Current `board_config.h` mirrors the table above
+> verbatim (external data pins + onboard DIN) and is therefore a placeholder.
+
 ## Repo layout
 
 ```
@@ -87,12 +101,17 @@ idf.py build
 idf.py flash monitor
 ```
 
-(Not yet functional — `components/aes67` hasn't been forked in yet.)
+`components/aes67` is vendored in (from `aes67-esp32p4`, Apache-2.0) and
+`main/` wires it up via `main/board_config.h`. Not yet build-verified in this
+environment — needs an ESP-IDF v5.5+ toolchain and, before flashing, the
+pinout confirmation noted above.
 
 ## Roadmap
 
-- [ ] Fork `aes67-esp32p4`'s `components/aes67` in as the starting point
-- [ ] Confirm pinout against actual target hardware
+- [x] Fork `aes67-esp32p4`'s `components/aes67` in as the starting point
+- [ ] Confirm pinout against actual target hardware (see note above — I2S rows
+      are inconsistent and `board_config.h` is a placeholder)
+- [ ] Build-verify with ESP-IDF v5.5+ (`idf.py set-target esp32p4 build`)
 - [ ] Get AES67 send/receive verified against real Dante gear in AES67 mode
 - [ ] Write clean-room Dante protocol notes (`docs/dante-protocol-notes.md`)
 - [ ] Prototype Dante mDNS advertisement/discovery

+ 57 - 0
components/aes67/CMakeLists.txt

@@ -0,0 +1,57 @@
+idf_component_register(
+    SRCS
+        "src/aes67.c"
+        "src/aes67_ptp.c"
+        "src/aes67_rtp.c"
+        "src/aes67_sap.c"
+        "src/aes67_sdp.c"
+        "src/aes67_session.c"
+        "src/aes67_audio.c"
+        "src/aes67_convert.c"
+        "src/aes67_net.c"
+        "src/aes67_mdns.c"
+        "src/aes67_hw_timer.c"
+        "src/aes67_l2tap_rx.c"
+        "src/aes67_webui.c"
+        # Bundled third-party: a minimal IEEE-1588 PTP daemon (Apache NuttX
+        # ptpd, ported to ESP-IDF by Espressif) and the EMAC time wrapper from
+        # the ESP-IDF ethernet/ptp example. Vendored here so the component is
+        # self-contained on the Component Registry. See third_party/*/README.md
+        # and the SPDX headers for provenance and licensing.
+        "third_party/ptpd/ptpd.c"
+        "third_party/esp_eth_time/esp_eth_time.c"
+    INCLUDE_DIRS
+        "include"
+    PRIV_INCLUDE_DIRS
+        "private_include"
+        "third_party/ptpd"
+        "third_party/ptpd/include"
+        "third_party/esp_eth_time"
+    PRIV_REQUIRES
+        esp_eth
+        esp_netif
+        esp_timer
+        esp_driver_i2s
+        mdns
+        lwip
+        esp_psram
+        esp_mm
+        esp_http_server
+    EMBED_FILES
+        "web/index.html.gz"
+)
+
+# ptpd.c is a third-party port with intentionally-unused helpers behind
+# Kconfig; silence that one warning for the bundled file only.
+set_source_files_properties(
+    third_party/ptpd/ptpd.c
+    PROPERTIES COMPILE_OPTIONS "-Wno-unused-function"
+)
+
+# Gzip the web frontend for embedding in flash
+add_custom_command(
+    OUTPUT ${CMAKE_CURRENT_SOURCE_DIR}/web/index.html.gz
+    COMMAND gzip -9 -k -f ${CMAKE_CURRENT_SOURCE_DIR}/web/index.html
+    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/web/index.html
+    COMMENT "Compressing web/index.html -> index.html.gz"
+)

+ 204 - 0
components/aes67/Kconfig

@@ -0,0 +1,204 @@
+menu "AES67 RAVENNA Component"
+
+    config AES67_MAX_SOURCES
+        int "Maximum number of source (TX) streams"
+        default 4
+        range 1 32
+        help
+            Maximum number of simultaneous AES67 source streams.
+
+    config AES67_MAX_SINKS
+        int "Maximum number of sink (RX) streams"
+        default 4
+        range 1 32
+        help
+            Maximum number of simultaneous AES67 sink streams.
+
+    config AES67_MAX_CHANNELS_PER_STREAM
+        int "Maximum channels per stream"
+        default 8
+        range 1 64
+        help
+            Maximum number of audio channels in a single RTP stream.
+
+    config AES67_DEFAULT_SAMPLE_RATE
+        int "Default sample rate (Hz)"
+        default 48000
+        help
+            Default audio sample rate. Common values: 44100, 48000, 96000.
+
+    config AES67_DEFAULT_PACKET_TIME_US
+        int "Default packet time (microseconds)"
+        default 1000
+        range 125 4000
+        help
+            Default packet time in microseconds. AES67 mandates support
+            for 1ms (1000us). Common values: 125, 250, 333, 1000.
+
+    config AES67_DEFAULT_WORD_LENGTH
+        int "Default word length (bytes per sample)"
+        default 3
+        range 2 4
+        help
+            Bytes per audio sample. 2=L16, 3=L24, 4=L32.
+
+    config AES67_DEFAULT_CHANNELS
+        int "Default channel count"
+        default 2
+        range 1 8
+        help
+            Default number of audio channels per stream (1=mono, 2=stereo, up to 8).
+
+    config AES67_RTP_PORT
+        int "Default RTP port"
+        default 5004
+        help
+            Default UDP port for RTP audio streams.
+
+    config AES67_RTP_MCAST_BASE
+        string "RTP multicast base address"
+        default "239.69.0.1"
+        help
+            Base multicast IP address for RTP streams. Each stream
+            gets the next sequential address from this base.
+
+    config AES67_SAP_ENABLED
+        bool "Enable SAP (Session Announcement Protocol)"
+        default y
+        help
+            Enable periodic SAP announcements for source discovery.
+
+    config AES67_MDNS_ENABLED
+        bool "Enable mDNS service advertisement"
+        default y
+        help
+            Advertise AES67 streams via mDNS/DNS-SD using the
+            _ravenna._udp service type.
+
+    config AES67_PTP_DOMAIN
+        int "PTP domain"
+        default 0
+        range 0 127
+        help
+            IEEE 1588 PTP domain number.
+
+    config AES67_PTP_PREFER_GRANDMASTER
+        bool "Prefer becoming PTP grandmaster"
+        default y
+        help
+            When no other grandmaster is found on the network, this device
+            will become PTP grandmaster. If another grandmaster with better
+            clock quality appears, this device will fall back to slave mode
+            according to the Best Master Clock algorithm.
+
+    config AES67_JITTER_BUFFER_MS
+        int "Jitter buffer size (ms)"
+        default 4
+        range 1 50
+        help
+            Sink stream jitter buffer depth in milliseconds.
+            Larger values increase latency but tolerate more network jitter.
+
+    config AES67_USE_PSRAM
+        bool "Use PSRAM for audio buffers"
+        default y
+        depends on SPIRAM
+        help
+            Allocate audio ring buffers and jitter buffers in PSRAM.
+            DMA buffers for I2S remain in internal SRAM.
+
+    config AES67_AUDIO_RING_BUFFER_MS
+        int "Audio ring buffer size (ms)"
+        default 20
+        range 5 100
+        help
+            Size of the audio ring buffer in milliseconds. This buffer
+            sits between I2S and the RTP engine.
+
+    config AES67_TASK_PRIORITY_PTP
+        int "PTP task priority"
+        default 20
+        range 1 25
+        help
+            FreeRTOS priority for the PTP synchronization task.
+
+    config AES67_TASK_PRIORITY_RTP
+        int "RTP task priority"
+        default 19
+        range 1 25
+        help
+            FreeRTOS priority for the RTP packet processing task.
+
+    config AES67_TASK_PRIORITY_AUDIO
+        int "Audio frame task priority"
+        default 22
+        range 1 25
+        help
+            FreeRTOS priority for the audio frame (TIC) processing task.
+
+    config AES67_TASK_PRIORITY_SAP
+        int "SAP task priority"
+        default 5
+        range 1 25
+        help
+            FreeRTOS priority for SAP announcement task.
+
+    config AES67_NODE_NAME
+        string "Default node name"
+        default "esp32p4-aes67"
+        help
+            Default device node name used in SDP and mDNS advertisements.
+
+    config AES67_I2S_PORT
+        int "I2S port number"
+        default 0
+        range 0 2
+        help
+            I2S peripheral port to use for audio I/O.
+
+    config AES67_I2S_MCK_GPIO
+        int "I2S MCLK GPIO"
+        default 13
+        help
+            GPIO pin for I2S master clock output.
+
+    config AES67_I2S_BCK_GPIO
+        int "I2S BCLK GPIO"
+        default 12
+        help
+            GPIO pin for I2S bit clock.
+
+    config AES67_I2S_WS_GPIO
+        int "I2S WS/LRCLK GPIO"
+        default 10
+        help
+            GPIO pin for I2S word select (LRCLK).
+
+    config AES67_I2S_DOUT_GPIO
+        int "I2S data out GPIO (ESP32 -> codec)"
+        default 9
+        help
+            GPIO pin for I2S serial data output to codec DAC (DSDIN).
+            On ESP32-P4-NANO this is GPIO9.
+
+    config AES67_I2S_DIN_GPIO
+        int "I2S data in GPIO (codec -> ESP32)"
+        default 11
+        help
+            GPIO pin for I2S serial data input from codec ADC (ASDOUT).
+            On ESP32-P4-NANO this is GPIO11.
+
+    config AES67_WEBUI_ENABLED
+        bool "Enable WebUI"
+        default y
+        help
+            HTTP REST API and WebSocket for monitoring and control.
+
+    config AES67_WEBUI_PORT
+        int "WebUI HTTP port"
+        default 80
+        depends on AES67_WEBUI_ENABLED
+        help
+            TCP port for the embedded web server.
+
+endmenu

+ 239 - 0
components/aes67/Kconfig.projbuild

@@ -0,0 +1,239 @@
+#
+# For a description of the syntax of this configuration file,
+# see the file kconfig-language.txt in the NuttX tools repository.
+#
+menu "PTP Daemon Configuration"
+
+    config NETUTILS_PTPD
+        bool "PTPD client/server"
+        default y
+    #depends on NET_IPv4
+    #depends on NET_IGMP
+    #depends on NET_UDP
+        help
+            Build a minimal implementation of IEEE-1588 precision time protocol.
+            Uses system gettimeofday() and adjtime() calls to synchronize clock
+            with a master clock through network, or to provide a master clock to
+            other systems.
+
+    if NETUTILS_PTPD
+
+        config NETUTILS_PTPD_DEBUG
+            bool "Enable PTP debug messages"
+            default n
+            depends on DEBUG_INFO
+            help
+                Enable PTP debug messages even if CONFIG_DEBUG_NET_INFO is not enabled.
+
+        config NETUTILS_PTPD_CLIENT
+            bool "Enable client support"
+            default y
+            help
+                Act as a PTP client, synchronizing the NuttX clock to a remote master
+                clock.
+
+        config NETUTILS_PTPD_SERVER
+            bool "Enable server support"
+            default n
+            help
+                Act as a PTP server, providing NuttX clock time to other systems.
+
+                Both server and client can be simultaneously enabled. NuttX will then
+                synchronize to a higher priority master clock, or act as a master
+                clock itself if it has the highest priority.
+                Refer to Best Master Clock algorithm in IEEE-1588 for details.
+
+        config NETUTILS_PTPD_STACKSIZE
+            int "PTP daemon stack stack size"
+            default 4096 # DEFAULT_TASK_STACKSIZE
+
+        config NETUTILS_PTPD_SERVERPRIO
+            int "PTP daemon priority"
+            default 100
+
+        config NETUTILS_PTPD_DOMAIN
+            int    "PTP domain selection"
+            default 0
+            range 0 127
+            help
+                Set PTP domain to participate in. Default domain is 0, other domains
+                can be used to isolate reference clocks from each other.
+
+        if NETUTILS_PTPD_SERVER
+
+            config NETUTILS_PTPD_PRIORITY1
+                int "PTP server priority1"
+                default 128
+                range 0 255
+                help
+                    Set clock priority to announce when acting as a PTP server.
+                    Lower value is higher priority.
+                    A higher priority1 clock will be selected without regard to announced
+                    clock quality fields.
+                    Refer to Best Master Clock algorithm in IEEE-1588 for details.
+
+            config NETUTILS_PTPD_PRIORITY2
+                int "PTP server priority2"
+                default 128
+                range 0 255
+                help
+                    Set clock subpriority to announce when acting as a PTP server.
+                    This will distinguish between two clocks that are equivalent in
+                    priority1, class and accuracy values.
+                    Lower value is higher priority.
+
+            config NETUTILS_PTPD_CLASS
+                int "PTP server class"
+                default 248
+                range 0 255
+                help
+                    Set master clock class to announce when acting as a PTP server.
+                    Lower value means higher quality clock source.
+                    248 is the default for unknown class.
+
+            config NETUTILS_PTPD_ACCURACY
+                int "PTP server accuracy"
+                default 254
+                range 0 255
+                help
+                    Set master clock accuracy to announce when acting as a PTP server.
+                    Logarithmic scale is defined in IEEE-1588:
+                    32: +-  25 ns
+                    33: +- 100 ns
+                    34: +- 250 ns
+                    35: +-   1 us
+                    36: +-   2.5 us
+                    37: +-  10 us
+                    38: +-  25 us
+                    39: +- 100 us
+                    40: +- 250 us
+                    41: +-   1 ms
+                    42: +-   2.5 ms
+                    43: +-  10 ms
+                    44: +-  25 ms
+                    45: +- 100 ms
+                    46: +- 250 ms
+                    47: +-   1 s
+                    48: +-  10 s
+                    49: +-  more than 10 s
+                    254: Unknown
+
+            config NETUTILS_PTPD_CLOCKSOURCE
+                int "PTP server clock source type"
+                default 160
+                range 0 255
+                help
+                    Set clock source type to announce when acting as a PTP server.
+                    Common values:
+                    32: GPS
+                    64: PTP
+                    80: NTP
+                    144: Other
+                    160: Internal oscillator
+
+            config NETUTILS_PTPD_SYNC_INTERVAL_MSEC
+                int "PTP server sync transmit interval (ms)"
+                default 1000
+                help
+                    How often to transmit sync packets in server mode.
+
+            config NETUTILS_PTPD_ANNOUNCE_INTERVAL_MSEC
+                int "PTP server announce transmit interval (ms)"
+                default 10000
+                help
+                    How often to transmit announce packets in server mode.
+
+            config NETUTILS_PTPD_TWOSTEP_SYNC
+                bool "PTP server sends two-step synchronization packets"
+                default y
+                help
+                    If enabled, sends a follow-up packet after every sync packet.
+                    This helps compensate for the time taken to initiate the transmission.
+
+            config NETUTILS_PTPD_DELAYRESP_INTERVAL
+                int "PTP server suggested interval of delay requests"
+                range 0 255
+                default 4
+                help
+                    When responding to a delay request, the server can inform the client
+                    how often it should test path delay. This is done using header field
+                    logMessageInterval. The delay will be 2^N seconds.
+
+                    Default value 4 results in 16 second interval.
+
+        endif # NETUTILS_PTPD_SERVER
+
+        if NETUTILS_PTPD_CLIENT
+
+            config NETUTILS_PTPD_TIMEOUT_MS
+                int "PTP client timeout for changing clock source (ms)"
+                default 60000
+                help
+                    If no packets are being received from currently chosen clock source,
+                    fall back to next best clock source after this many seconds.
+
+            config NETUTILS_PTPD_SETTIME_THRESHOLD_MS
+                int "PTP client threshold for changing system time (ms)"
+                default 1000
+                help
+                    If difference between local and remote clock exceeds this threshold,
+                    time is reset with settimeofday() instead of changing the rate with
+                    adjtime().
+
+        # Commented options not used by ESP_PTP
+        #config NETUTILS_PTPD_MULTICAST_TIMEOUT_MS
+        #    int "PTP client timeout to rejoin multicast group (ms)"
+        #    default 30000 #esp32
+        #    default 0
+        #    help
+        #        If no PTP multicast packets are being received, attempt to rejoin the
+        #        multicast group. This can be necessary if network topology changes, or
+        #        depending on hardware, after some error recovery events.
+        #        Set to 0 to disable.
+
+        #config NETUTILS_PTPD_DRIFT_AVERAGE_S
+        #    int "PTP client clock drift rate averaging time (s)"
+        #    default 600
+        #    range 10 86400
+        #    help
+        #        Clock drift rate is averaged over this time pediod. Larger value
+        #        gives more stable estimate but reacts slower to crystal oscillator speed
+        #        changes (such as caused by temperature changes).
+
+            config NETUTILS_PTPD_SEND_DELAYREQ
+                bool "PTP client enable delay requests"
+                default n
+                help
+                    If enabled, sends delay request messages to measure the network delay
+                    to server. If disabled, assumes zero delay.
+
+            if NETUTILS_PTPD_SEND_DELAYREQ
+
+                config NETUTILS_PTPD_MAX_PATH_DELAY_NS
+                    int "PTP client maximum path delay (ns)"
+                    default 100000
+                    range 1 1000000000
+                    help
+                        Measured path delay longer than this is ignored. Delay requests are
+                        also not transmitted until clock synchronization is better than this.
+
+                config NETUTILS_PTPD_DELAYREQ_AVGCOUNT
+                    int "PTP client path delay averaging count"
+                    default 100
+                    help
+                        Measured path delay is averaged over this many samples.
+
+                config NETUTILS_PTPD_PATH_DELAY_STABILITY_NS
+                    int "PTP client send delay request when clock stability (ns)"
+                    default 250
+                    help
+                        Sends path delay request only once the internal clock is stable and skews only
+                        in defined interval.
+
+            endif # NETUTILS_PTPD_SEND_DELAYREQ
+
+        endif # NETUTILS_PTPD_CLIENT
+
+    endif # NETUTILS_PTPD
+
+endmenu # PTP Daemon Configuration

+ 20 - 0
components/aes67/NOTICE

@@ -0,0 +1,20 @@
+aes67 (datanoisetv/aes67)
+Copyright 2026 DatanoiseTV
+
+This product bundles third-party code under the Apache License 2.0, kept in
+third_party/ with original SPDX headers and provenance intact:
+
+  third_party/ptpd/
+    A minimal IEEE-1588 PTP daemon derived from Apache NuttX
+    (apps/netutils/ptpd), ported to ESP-IDF.
+    Copyright 2020-2024 The Apache Software Foundation.
+    Portions Copyright 2024-2025 Espressif Systems (Shanghai) CO LTD.
+    Licensed under the Apache License, Version 2.0.
+
+  third_party/esp_eth_time/
+    EMAC time-control wrapper from the ESP-IDF ethernet/ptp example.
+    Copyright Espressif Systems (Shanghai) CO LTD.
+    Licensed under the Apache License, Version 2.0.
+
+You may obtain a copy of the Apache License 2.0 at:
+  http://www.apache.org/licenses/LICENSE-2.0

+ 113 - 0
components/aes67/README.md

@@ -0,0 +1,113 @@
+# aes67
+
+AES67 / RAVENNA audio-over-IP for the **ESP32-P4**.
+
+An audio-over-IP endpoint — PTP-synchronized RTP audio with SAP/SDP
+discovery — running on a microcontroller-class SoC. The clock is
+disciplined off the ESP32-P4 EMAC's IEEE-1588 hardware timestamping, audio
+is moved by ISR-driven I2S DMA, and the receive path can bypass lwIP for
+low latency.
+
+Interop-tested on real hardware against **Merging Technologies SIENNA**
+(commercial RAVENNA) and the **aes67-linux-daemon** running on a Raspberry
+Pi.
+
+> **ESP32-P4 only.** The implementation depends on the P4 EMAC's hardware
+> PTP timestamping and the P4 I2S/APLL clocking. It is not portable to
+> other ESP32 targets as-is.
+
+## What this component provides
+
+The component is the AES67 **engine** plus the drivers it needs:
+
+- **PTP (IEEE-1588v2)** clock sync off the EMAC hardware timestamp unit —
+  slave mode (locks to an external grandmaster) and grandmaster mode (BMCA
+  promotes this node to GM when no better clock is present). The PTP clock
+  identity is derived from the MAC via EUI-64.
+- **RTP engine** — source (TX) and sink (RX), L16 / L24 / L32 / AM824
+  codecs via a function-pointer conversion dispatch, 1–8 channels, 44.1 and
+  48 kHz, packet times from 0.125 ms to 4 ms.
+- **An `esp_vfs_l2tap` receive path** that reads RTP frames at L2 and
+  bypasses the lwIP socket layer for low-latency reception.
+- **ISR-driven I2S playback** on an APLL-derived clock, with sub-ppm clock
+  adjustment for media-clock recovery and sample-hold on underrun.
+- **SAP (RFC 2974)** announce/discover, **SDP** generate/parse
+  (AES67-compliant: `a=ts-refclk`, `a=mediaclk`, fractional ptime),
+  **mDNS** `_ravenna._udp` advertisement.
+- An optional **embedded Web UI** (gzipped SPA + WebSocket) for live status
+  and stream management.
+
+### What lives in the example, not the component
+
+The repository's [`main/`](https://github.com/DatanoiseTV/aes67-esp32p4/tree/main/main)
+example shows the board bring-up (Ethernet/PHY, ES8311 codec) and one extra
+integration: an **`esp_eth` Rx hook** that intercepts RTP frames in the
+driver callback — before lwIP — for the absolute lowest latency (the ~0.7 ms
+figure). That hook is example code you copy and adapt to your board, not a
+component API. The component's own `esp_vfs_l2tap` path is the portable
+low-latency option.
+
+## Quick start
+
+```bash
+idf.py add-dependency "datanoisetv/aes67^2.6.0"
+```
+
+```c
+#include "aes67.h"
+#include "aes67_session.h"
+
+aes67_config_t cfg = AES67_CONFIG_DEFAULT();
+cfg.net.eth_handle = my_eth_handle;     /* you install esp_eth yourself */
+
+aes67_node_handle_t node;
+aes67_node_init(&cfg, &node);
+aes67_node_start(node);
+
+aes67_session_handle_t session;
+aes67_node_get_session(node, &session);
+
+uint8_t id;
+aes67_session_add_source(session, "My Source", 2, AES67_CODEC_L24, &id);
+/* add a sink from a discovered SDP: */
+aes67_session_add_sink(session, "My Sink", remote_sdp_text, &id);
+```
+
+A complete, board-specific example (ESP32-P4-Nano + ES8311) lives in the
+[repository](https://github.com/DatanoiseTV/aes67-esp32p4).
+
+## Configuration
+
+Options live under **AES67 RAVENNA Component** and **PTP Daemon
+Configuration** in `idf.py menuconfig` — max sources/sinks, channels,
+sample rate, packet time, codec, RTP port, PTP domain, jitter-buffer depth,
+PSRAM usage, and task priorities.
+
+For low-latency audio the example's `sdkconfig.defaults` also enables
+802.3x flow control (`CONFIG_ETH_*`), lwIP IRAM optimizations, and pins the
+network tasks to core 0 — see the repository for the full set.
+
+## Requirements & limitations
+
+- **ESP-IDF v5.5+**, target **esp32p4**.
+- Sample rates **44.1 / 48 kHz** (no 96 kHz yet).
+- The lowest-latency `esp_eth` Rx hook is example wiring, not a component
+  API (see above).
+- The bundled Web UI is optional and can be left out.
+
+## Bundled third-party code
+
+To keep the component self-contained on the registry, it vendors two
+Apache-2.0 components, with their license headers and per-file provenance
+intact:
+
+- `third_party/ptpd/` — a minimal IEEE-1588 PTP daemon (Apache NuttX
+  `ptpd`, ported to ESP-IDF by Espressif).
+- `third_party/esp_eth_time/` — the EMAC time-control wrapper from the
+  ESP-IDF `ethernet/ptp` example (Espressif).
+
+See [`NOTICE`](NOTICE) and the SPDX headers in those files.
+
+## License
+
+Apache-2.0. See the SPDX headers and `NOTICE`.

+ 22 - 0
components/aes67/idf_component.yml

@@ -0,0 +1,22 @@
+version: "2.6.0"
+description: "AES67 / RAVENNA audio-over-IP for the ESP32-P4 — hardware-PTP-synced RTP audio (L16/L24/L32/AM824), SAP/SDP discovery, ISR-driven I2S, sub-millisecond latency. Interop-tested against Merging SIENNA and the aes67-linux-daemon."
+url: "https://github.com/DatanoiseTV/aes67-esp32p4"
+repository: "https://github.com/DatanoiseTV/aes67-esp32p4.git"
+issues: "https://github.com/DatanoiseTV/aes67-esp32p4/issues"
+documentation: "https://github.com/DatanoiseTV/aes67-esp32p4#readme"
+license: "Apache-2.0"
+tags:
+  - aes67
+  - ravenna
+  - audio
+  - network-audio
+  - ptp
+  - rtp
+  - esp32p4
+targets:
+  - esp32p4
+dependencies:
+  idf:
+    version: ">=5.5.0"
+  espressif/mdns:
+    version: ">=1.0.0"

+ 131 - 0
components/aes67/include/aes67.h

@@ -0,0 +1,131 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67/RAVENNA audio-over-IP component for ESP-IDF
+ *
+ * Top-level API for initialization, configuration and lifecycle management.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/stream_buffer.h"
+#include "aes67_config.h"
+#include "aes67_session.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Opaque handle for an AES67 node instance */
+typedef struct aes67_node *aes67_node_handle_t;
+
+/* Event types posted to the application event loop */
+typedef enum {
+    AES67_EVENT_PTP_LOCKED,         /* PTP clock achieved lock */
+    AES67_EVENT_PTP_UNLOCKED,       /* PTP clock lost lock */
+    AES67_EVENT_SOURCE_ADDED,       /* Local source stream created */
+    AES67_EVENT_SOURCE_REMOVED,     /* Local source stream removed */
+    AES67_EVENT_SINK_ADDED,         /* Local sink stream created */
+    AES67_EVENT_SINK_REMOVED,       /* Local sink stream removed */
+    AES67_EVENT_REMOTE_FOUND,       /* Remote source discovered via SAP/mDNS */
+    AES67_EVENT_REMOTE_LOST,        /* Remote source timed out */
+    AES67_EVENT_SINK_RX_ACTIVE,     /* Sink is receiving audio data */
+    AES67_EVENT_SINK_RX_TIMEOUT,    /* Sink stopped receiving audio data */
+} aes67_event_t;
+
+/* Event base declaration for ESP event loop */
+ESP_EVENT_DECLARE_BASE(AES67_EVENT);
+
+/**
+ * Initialize the AES67 node with the given configuration.
+ *
+ * Brings up PTP, RTP engine, SAP, mDNS and audio subsystems.
+ * The Ethernet interface must already be initialized and connected.
+ *
+ * @param config  Node configuration. NULL uses defaults from Kconfig.
+ * @param handle  Output handle for the created node instance.
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_node_init(const aes67_config_t *config, aes67_node_handle_t *handle);
+
+/**
+ * Start the AES67 node. Begins PTP sync, SAP announcements and enables
+ * stream processing.
+ */
+esp_err_t aes67_node_start(aes67_node_handle_t handle);
+
+/**
+ * Stop the AES67 node. Tears down all active streams and halts PTP.
+ */
+esp_err_t aes67_node_stop(aes67_node_handle_t handle);
+
+/**
+ * Destroy the AES67 node and free all resources.
+ */
+esp_err_t aes67_node_destroy(aes67_node_handle_t handle);
+
+/**
+ * Get the node's current configuration (read-only snapshot).
+ */
+esp_err_t aes67_node_get_config(aes67_node_handle_t handle, aes67_config_t *config);
+
+/**
+ * Get the session manager handle from a running node.
+ * Useful for adding/removing sources and sinks at the application level.
+ */
+esp_err_t aes67_node_get_session(aes67_node_handle_t handle,
+                                  aes67_session_handle_t *session);
+
+/**
+ * Get the audio driver handle from a running node.
+ */
+#include "aes67_audio.h"
+esp_err_t aes67_node_get_audio(aes67_node_handle_t handle,
+                               aes67_audio_handle_t *audio);
+
+/**
+ * Get the SAP handle from a running node.
+ */
+#include "aes67_sap.h"
+esp_err_t aes67_node_get_sap(aes67_node_handle_t handle,
+                              aes67_sap_handle_t *sap);
+
+/**
+ * Get the PTP handle from a running node.
+ */
+#include "aes67_ptp.h"
+esp_err_t aes67_node_get_ptp(aes67_node_handle_t handle,
+                              aes67_ptp_handle_t *ptp);
+
+/**
+ * Get the RTP engine handle from a running node.
+ */
+#include "aes67_rtp.h"
+esp_err_t aes67_node_get_rtp(aes67_node_handle_t handle,
+                              aes67_rtp_engine_handle_t *rtp);
+
+/**
+ * Provide an external playout stream buffer for the node's playback task.
+ *
+ * By default the playback task consumes audio from the RTP engine's own
+ * stream buffer. An integration that intercepts RTP frames earlier (for
+ * example, an esp_eth Rx hook that parses audio in the driver callback and
+ * feeds it in directly) can register its own buffer here; when set and
+ * non-NULL, the playback task reads from it instead. Pass NULL to clear.
+ *
+ * Optional. Call before aes67_node_start(); has no effect on the ISR-driven
+ * I2S path, which is fed via aes67_audio_slot_write().
+ */
+esp_err_t aes67_node_set_playout_buf(aes67_node_handle_t handle,
+                                      StreamBufferHandle_t sbuf);
+
+#ifdef __cplusplus
+}
+#endif

+ 177 - 0
components/aes67/include/aes67_audio.h

@@ -0,0 +1,177 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Audio I/O driver using I2S TDM on ESP32-P4.
+ *
+ * Manages I2S DMA transfers for audio capture and playback. Provides
+ * the audio frame callback (TIC) that drives the RTP engine's timing.
+ * DMA buffers are in internal SRAM; larger ring buffers use PSRAM.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "aes67_config.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Audio frame callback (TIC). Invoked once per audio frame period.
+ * The callback should read from sink jitter buffers and write to
+ * source ring buffers. */
+typedef void (*aes67_audio_frame_cb_t)(uint32_t frame_count, void *user_data);
+
+/* Opaque audio driver handle */
+typedef struct aes67_audio_ctx *aes67_audio_handle_t;
+
+/**
+ * Initialize the I2S audio driver.
+ *
+ * @param audio_config  Audio parameters (sample rate, channels, format)
+ * @param pins          I2S pin configuration
+ * @param use_psram     If true, allocate ring buffers in PSRAM
+ * @param ring_buf_ms   Ring buffer size in milliseconds
+ * @param handle        Output handle
+ */
+esp_err_t aes67_audio_init(const aes67_audio_config_t *audio_config,
+                           const aes67_i2s_pins_t *pins,
+                           bool use_psram,
+                           uint16_t ring_buf_ms,
+                           aes67_audio_handle_t *handle);
+
+/**
+ * Start audio I/O (enables I2S DMA).
+ */
+esp_err_t aes67_audio_start(aes67_audio_handle_t handle);
+
+/**
+ * Stop audio I/O.
+ */
+esp_err_t aes67_audio_stop(aes67_audio_handle_t handle);
+
+/**
+ * Destroy audio driver and free all buffers.
+ */
+esp_err_t aes67_audio_destroy(aes67_audio_handle_t handle);
+
+/**
+ * Register the audio frame callback. The callback is invoked from a
+ * high-priority task each time a DMA transfer completes (one frame period).
+ */
+esp_err_t aes67_audio_register_frame_cb(aes67_audio_handle_t handle,
+                                        aes67_audio_frame_cb_t cb,
+                                        void *user_data);
+
+/**
+ * Write samples to the playback ring buffer (sink path).
+ * Called from the RTP jitter buffer read path.
+ *
+ * @param handle        Audio driver handle
+ * @param samples       Interleaved audio samples
+ * @param frame_count   Number of frames to write
+ * @return ESP_OK, or ESP_ERR_NO_MEM if buffer full
+ */
+esp_err_t aes67_audio_write_playback(aes67_audio_handle_t handle,
+                                     const void *samples, uint32_t frame_count);
+
+/**
+ * Read samples from the capture ring buffer (source path).
+ * Called by the RTP engine to get audio data for transmission.
+ *
+ * @param handle        Audio driver handle
+ * @param samples       Output buffer for interleaved samples
+ * @param frame_count   Number of frames to read
+ * @return ESP_OK, or ESP_ERR_NOT_FOUND if insufficient data
+ */
+esp_err_t aes67_audio_read_capture(aes67_audio_handle_t handle,
+                                   void *samples, uint32_t frame_count);
+
+/**
+ * Get current audio parameters.
+ */
+esp_err_t aes67_audio_get_config(aes67_audio_handle_t handle,
+                                 aes67_audio_config_t *config);
+
+/**
+ * Get buffer fill levels (for monitoring).
+ *
+ * @param handle            Audio driver handle
+ * @param capture_frames    Frames available in capture ring buffer
+ * @param playback_frames   Frames available in playback ring buffer
+ */
+esp_err_t aes67_audio_get_buffer_levels(aes67_audio_handle_t handle,
+                                        uint32_t *capture_frames,
+                                        uint32_t *playback_frames);
+
+/**
+ * Write int32 samples directly to I2S via i2s_channel_write.
+ * Blocks until DMA has room (used for DMA-paced playback).
+ */
+esp_err_t aes67_audio_direct_write(aes67_audio_handle_t handle,
+                                   const int32_t *samples, uint32_t frame_count);
+
+/**
+ * Get the I2S TX channel handle (for DMA-paced playback).
+ */
+#include "driver/i2s_types.h"
+i2s_chan_handle_t aes67_audio_get_tx_chan(aes67_audio_handle_t handle);
+
+/**
+ * Write int16 stereo samples to the staging ring buffer.
+ * Called by the playback task; the DMA ISR reads from this ring.
+ */
+esp_err_t aes67_audio_staging_write(aes67_audio_handle_t handle,
+                                     const int16_t *samples, uint32_t sample_count);
+
+/**
+ * Get DMA staging statistics.
+ */
+void aes67_audio_get_staging_stats(aes67_audio_handle_t handle,
+                                    uint32_t *isr_count, uint32_t *underruns);
+
+/**
+ * Initialize ISR-driven playback via slot ring. Must be called
+ * BEFORE aes67_audio_start(). The DMA ISR reads from the slot ring
+ * which is fed by aes67_audio_slot_write().
+ */
+#include "freertos/stream_buffer.h"
+void aes67_audio_set_isr_stream_buf(aes67_audio_handle_t handle,
+                                     StreamBufferHandle_t sbuf);
+
+/**
+ * Write one packet to the slot ring (called from Ethernet hook).
+ */
+void aes67_audio_slot_write(const void *data, uint32_t len);
+
+/**
+ * Log ISR statistics.
+ */
+void aes67_audio_log_isr_stats(void);
+
+/**
+ * Initialize I2S RX capture for source (TX) streams.
+ * The DMA ISR copies captured int32 samples to a stream buffer.
+ * Must be called BEFORE aes67_audio_start().
+ */
+void aes67_audio_init_capture(aes67_audio_handle_t handle);
+
+/**
+ * Get the capture stream buffer for reading captured I2S audio.
+ */
+StreamBufferHandle_t aes67_audio_get_capture_buf(aes67_audio_handle_t handle);
+
+/**
+ * Adjust the I2S TX MCLK divider for adaptive clock recovery.
+ * ppm_adj: parts-per-million adjustment. +100 = 0.01% faster.
+ */
+void aes67_audio_adjust_clock_ppm(aes67_audio_handle_t handle, int32_t ppm_adj);
+
+#ifdef __cplusplus
+}
+#endif

+ 233 - 0
components/aes67/include/aes67_config.h

@@ -0,0 +1,233 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67 configuration structures.
+ *
+ * Kconfig provides compile-time defaults; these structs allow runtime
+ * override before node initialization.
+ *
+ * Key design: all buffer sizes and frame counts are COMPUTED from the
+ * base audio parameters (sample_rate, channels, word_length, packet_time_us)
+ * via aes67_stream_params_compute(). No hardcoded 192, 2, 48000, etc.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_eth.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ---- Version ---- */
+#define AES67_VERSION_MAJOR  2
+#define AES67_VERSION_MINOR  2
+#define AES67_VERSION_PATCH  0
+
+/* ---- Limits ---- */
+#define AES67_MAX_CHANNELS_PER_STREAM  8
+#define AES67_MAX_TIC_FRAMES           1024
+#define AES67_MIN_TIC_FRAMES           48
+#define AES67_RTP_HEADER_SIZE          12
+#define AES67_MAX_WORD_LENGTH          4    /* AM824/L32 = 4 bytes */
+
+/* ---- Enums ---- */
+
+/* Audio codec identifiers matching AES67/RAVENNA conventions */
+typedef enum {
+    AES67_CODEC_L16 = 0,   /* 16-bit linear PCM, word_length=2 */
+    AES67_CODEC_L24,        /* 24-bit linear PCM, word_length=3 */
+    AES67_CODEC_L32,        /* 32-bit linear PCM, word_length=4 */
+    AES67_CODEC_AM824,      /* IEC 61937 / AM824, word_length=4 */
+} aes67_codec_t;
+
+/* Output mode for received audio data */
+typedef enum {
+    AES67_OUTPUT_I2S = 0,           /* Direct I2S output (default) */
+    AES67_OUTPUT_STREAM_BUFFER,     /* FreeRTOS StreamBuffer for app processing */
+    AES67_OUTPUT_CALLBACK,          /* User callback per packet */
+} aes67_output_mode_t;
+
+/* Callback for received audio data (when output_mode == AES67_OUTPUT_CALLBACK) */
+typedef void (*aes67_audio_data_cb_t)(const int32_t *samples,
+                                       uint32_t frame_count,
+                                       uint8_t channels,
+                                       uint8_t word_length,
+                                       uint32_t sample_rate,
+                                       void *user_data);
+
+/* ---- Stream Parameters (computed, not configured) ---- */
+
+/*
+ * Derived parameters computed from the base audio config.
+ * Use aes67_stream_params_compute() to populate.
+ * These replace all hardcoded 192, 768, 1536, etc.
+ */
+typedef struct {
+    /* From config */
+    uint32_t sample_rate;
+    uint8_t  channels;
+    uint8_t  word_length;           /* Bytes per sample on the wire (2/3/4) */
+    uint16_t packet_time_us;
+
+    /* Computed */
+    uint32_t samples_per_packet;    /* Frames per RTP packet (e.g., 48, 192) */
+    uint32_t frame_bytes_wire;      /* Bytes per frame on wire: channels * word_length */
+    uint32_t frame_bytes_native;    /* Bytes per frame in memory: channels * 4 (int32) */
+    uint32_t packet_payload_size;   /* RTP payload bytes: spp * frame_bytes_wire */
+    uint32_t packet_native_size;    /* Converted packet bytes: spp * frame_bytes_native */
+    uint32_t max_packet_payload;    /* Max RTP payload: MAX_TIC_FRAMES * frame_bytes_wire */
+} aes67_stream_params_t;
+
+/* Compute derived parameters from base config */
+static inline void aes67_stream_params_compute(aes67_stream_params_t *p,
+                                                uint32_t sample_rate,
+                                                uint8_t channels,
+                                                uint8_t word_length,
+                                                uint16_t packet_time_us)
+{
+    p->sample_rate = sample_rate;
+    p->channels = channels;
+    p->word_length = word_length;
+    p->packet_time_us = packet_time_us;
+
+    p->samples_per_packet = (sample_rate * (uint32_t)packet_time_us) / 1000000;
+    if (p->samples_per_packet == 0) p->samples_per_packet = 48;
+    if (p->samples_per_packet > AES67_MAX_TIC_FRAMES) {
+        p->samples_per_packet = AES67_MAX_TIC_FRAMES;
+    }
+
+    p->frame_bytes_wire = (uint32_t)channels * word_length;
+    p->frame_bytes_native = (uint32_t)channels * sizeof(int32_t);
+    p->packet_payload_size = p->samples_per_packet * p->frame_bytes_wire;
+    p->packet_native_size = p->samples_per_packet * p->frame_bytes_native;
+    p->max_packet_payload = AES67_MAX_TIC_FRAMES * p->frame_bytes_wire;
+}
+
+/* Get word_length from codec enum */
+static inline uint8_t aes67_codec_word_length(aes67_codec_t codec)
+{
+    switch (codec) {
+    case AES67_CODEC_L16:  return 2;
+    case AES67_CODEC_L24:  return 3;
+    case AES67_CODEC_L32:  return 4;
+    case AES67_CODEC_AM824: return 4;
+    default: return 3;
+    }
+}
+
+/* Get codec from word_length */
+static inline aes67_codec_t aes67_word_length_codec(uint8_t wl)
+{
+    switch (wl) {
+    case 2: return AES67_CODEC_L16;
+    case 3: return AES67_CODEC_L24;
+    case 4: return AES67_CODEC_L32;
+    default: return AES67_CODEC_L24;
+    }
+}
+
+/* ---- Configuration Structs ---- */
+
+/* Network configuration */
+typedef struct {
+    char interface_name[16];            /* Ethernet interface, e.g. "ETH_0" */
+    esp_eth_handle_t eth_handle;        /* Ethernet driver handle */
+    char rtp_mcast_base[16];            /* Base multicast address for RTP */
+    uint16_t rtp_port;                  /* RTP port (default 5004) */
+    uint8_t ttl;                        /* Multicast TTL */
+    uint8_t dscp;                       /* DSCP for QoS (default 46 = EF) */
+} aes67_net_config_t;
+
+/* PTP configuration */
+typedef struct {
+    uint8_t domain;                     /* PTP domain 0-127 */
+    uint8_t dscp;                       /* DSCP for PTP packets */
+    bool prefer_grandmaster;            /* Become GM if no other master found */
+} aes67_ptp_config_t;
+
+/* Audio configuration */
+typedef struct {
+    uint32_t sample_rate;               /* Sample rate in Hz (44100 or 48000) */
+    uint8_t word_length;                /* Bytes per sample (2, 3, or 4) */
+    uint16_t packet_time_us;            /* Packet time in microseconds */
+    uint8_t channels;                   /* Number of audio channels (1-8) */
+    aes67_codec_t codec;                /* Audio codec */
+} aes67_audio_config_t;
+
+/* I2S pin configuration */
+typedef struct {
+    int mck_gpio;
+    int bck_gpio;
+    int ws_gpio;
+    int dout_gpio;
+    int din_gpio;
+} aes67_i2s_pins_t;
+
+/* Top-level node configuration */
+typedef struct {
+    char node_name[64];                 /* Node name for SDP/mDNS */
+    aes67_net_config_t net;
+    aes67_ptp_config_t ptp;
+    aes67_audio_config_t audio;
+    aes67_i2s_pins_t i2s_pins;
+    uint16_t jitter_buffer_ms;          /* Jitter buffer depth in ms */
+    uint16_t ring_buffer_ms;            /* Audio ring buffer size in ms */
+    bool use_psram;                     /* Allocate large buffers in PSRAM */
+    bool sap_enabled;                   /* Enable SAP announcements */
+    bool mdns_enabled;                  /* Enable mDNS advertisement */
+
+    /* Output routing */
+    aes67_output_mode_t output_mode;    /* How to deliver received audio */
+    aes67_audio_data_cb_t audio_cb;     /* Callback (when mode == CALLBACK) */
+    void *audio_cb_user_data;           /* User data for callback */
+} aes67_config_t;
+
+/* Initialize a config struct with Kconfig defaults */
+#define AES67_CONFIG_DEFAULT() { \
+    .node_name = CONFIG_AES67_NODE_NAME, \
+    .net = { \
+        .interface_name = "ETH_0", \
+        .eth_handle = NULL, \
+        .rtp_mcast_base = CONFIG_AES67_RTP_MCAST_BASE, \
+        .rtp_port = CONFIG_AES67_RTP_PORT, \
+        .ttl = 64, \
+        .dscp = 46, \
+    }, \
+    .ptp = { \
+        .domain = CONFIG_AES67_PTP_DOMAIN, \
+        .dscp = 46, \
+        .prefer_grandmaster = true, \
+    }, \
+    .audio = { \
+        .sample_rate = CONFIG_AES67_DEFAULT_SAMPLE_RATE, \
+        .word_length = CONFIG_AES67_DEFAULT_WORD_LENGTH, \
+        .packet_time_us = CONFIG_AES67_DEFAULT_PACKET_TIME_US, \
+        .channels = CONFIG_AES67_DEFAULT_CHANNELS, \
+        .codec = AES67_CODEC_L24, \
+    }, \
+    .i2s_pins = { \
+        .mck_gpio = CONFIG_AES67_I2S_MCK_GPIO, \
+        .bck_gpio = CONFIG_AES67_I2S_BCK_GPIO, \
+        .ws_gpio = CONFIG_AES67_I2S_WS_GPIO, \
+        .dout_gpio = CONFIG_AES67_I2S_DOUT_GPIO, \
+        .din_gpio = CONFIG_AES67_I2S_DIN_GPIO, \
+    }, \
+    .jitter_buffer_ms = CONFIG_AES67_JITTER_BUFFER_MS, \
+    .ring_buffer_ms = CONFIG_AES67_AUDIO_RING_BUFFER_MS, \
+    .use_psram = true, \
+    .sap_enabled = true, \
+    .mdns_enabled = true, \
+    .output_mode = AES67_OUTPUT_I2S, \
+    .audio_cb = NULL, \
+    .audio_cb_user_data = NULL, \
+}
+
+#ifdef __cplusplus
+}
+#endif

+ 82 - 0
components/aes67/include/aes67_convert.h

@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Audio sample format conversion routines.
+ *
+ * Based on RAVENNA MTConvert pattern. Handles conversion between native
+ * (little-endian, 32-bit left-justified) audio buffers and network
+ * (big-endian, packed) RTP payload formats.
+ *
+ * Function pointer types allow per-stream codec dispatch without
+ * branching in the hot path.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* --- Function pointer types (MTConvert pattern) --- */
+
+/* Convert interleaved int32 native samples to packed big-endian wire format.
+ * Called once per TX packet. Set at stream creation based on codec. */
+typedef void (*aes67_convert_to_net_fn)(const int32_t *src, uint8_t *dst,
+                                        uint32_t frame_count, uint8_t channels);
+
+/* Convert packed big-endian wire format to interleaved int32 native samples.
+ * Called once per RX packet. Set at stream creation based on codec. */
+typedef void (*aes67_convert_from_net_fn)(const uint8_t *src, int32_t *dst,
+                                          uint32_t frame_count, uint8_t channels);
+
+/* Resolve the appropriate conversion functions for a given codec/word_length */
+aes67_convert_to_net_fn aes67_get_to_net_fn(uint8_t word_length);
+aes67_convert_from_net_fn aes67_get_from_net_fn(uint8_t word_length);
+
+/* --- Per-codec conversion functions --- */
+
+/* L16: 16-bit linear PCM (2 bytes/sample on wire) */
+void aes67_convert_to_l16(const int32_t *src, uint8_t *dst,
+                          uint32_t frame_count, uint8_t channels);
+void aes67_convert_from_l16(const uint8_t *src, int32_t *dst,
+                            uint32_t frame_count, uint8_t channels);
+
+/* L24: 24-bit linear PCM (3 bytes/sample on wire) */
+void aes67_convert_to_l24(const int32_t *src, uint8_t *dst,
+                          uint32_t frame_count, uint8_t channels);
+void aes67_convert_from_l24(const uint8_t *src, int32_t *dst,
+                            uint32_t frame_count, uint8_t channels);
+
+/* L32: 32-bit linear PCM (4 bytes/sample on wire) */
+void aes67_convert_to_l32(const int32_t *src, uint8_t *dst,
+                          uint32_t frame_count, uint8_t channels);
+void aes67_convert_from_l32(const uint8_t *src, int32_t *dst,
+                            uint32_t frame_count, uint8_t channels);
+
+/* AM824: IEC 61937 encapsulated in 4-byte words.
+ * Same wire format as L32 but with channel status and validity bits
+ * in the lower 8 bits. For basic PCM passthrough, the conversion is
+ * identical to L32. */
+void aes67_convert_to_am824(const int32_t *src, uint8_t *dst,
+                             uint32_t frame_count, uint8_t channels);
+void aes67_convert_from_am824(const uint8_t *src, int32_t *dst,
+                               uint32_t frame_count, uint8_t channels);
+
+/* Generic dispatch (uses switch internally -- prefer function pointers
+ * in hot paths for zero-branch overhead) */
+void aes67_convert_to_net(const int32_t *src, uint8_t *dst,
+                          uint32_t frame_count, uint8_t channels,
+                          uint8_t word_length);
+void aes67_convert_from_net(const uint8_t *src, int32_t *dst,
+                            uint32_t frame_count, uint8_t channels,
+                            uint8_t word_length);
+
+#ifdef __cplusplus
+}
+#endif

+ 38 - 0
components/aes67/include/aes67_l2tap_rx.h

@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * L2 TAP based RTP receiver for AES67.
+ *
+ * Bypasses the lwIP stack entirely by reading raw Ethernet frames via
+ * the ESP-IDF L2 TAP virtual filesystem interface. Filters for IPv4/UDP
+ * multicast RTP packets and writes decoded audio directly to I2S.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include "driver/i2s_types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Start the L2 TAP RTP receiver task.
+ *
+ * Opens /dev/net/tap on the ETH_0 interface, spawns a high-priority
+ * task on core 0 that reads raw Ethernet frames, filters for multicast
+ * RTP on the given port, converts L24 audio to int16 mono, and writes
+ * directly to the I2S TX channel with DMA pacing.
+ *
+ * @param tx_chan   I2S TX channel handle for audio output
+ * @param rtp_port  UDP destination port to filter (typically 5004)
+ */
+void aes67_l2tap_rx_start(i2s_chan_handle_t tx_chan, uint16_t rtp_port);
+
+#ifdef __cplusplus
+}
+#endif

+ 95 - 0
components/aes67/include/aes67_net.h

@@ -0,0 +1,95 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Network utilities for AES67.
+ *
+ * Multicast socket management, IGMP join/leave, IP address helpers.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Create a UDP socket bound to the given port with multicast support.
+ *
+ * @param port      Local port to bind
+ * @param reuse     Allow address reuse
+ * @return Socket fd on success, -1 on error
+ */
+int aes67_net_create_udp_socket(uint16_t port, bool reuse);
+
+/**
+ * Join a multicast group on the given socket.
+ *
+ * @param sock          Socket fd
+ * @param mcast_addr    Multicast group address string (e.g. "239.69.0.1")
+ * @param if_addr       Local interface address string (NULL for default)
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_net_join_multicast(int sock, const char *mcast_addr,
+                                   const char *if_addr);
+
+/**
+ * Leave a multicast group.
+ */
+esp_err_t aes67_net_leave_multicast(int sock, const char *mcast_addr,
+                                    const char *if_addr);
+
+/**
+ * Set multicast TTL on a socket.
+ */
+esp_err_t aes67_net_set_multicast_ttl(int sock, uint8_t ttl);
+
+/**
+ * Set DSCP/TOS on a socket.
+ */
+esp_err_t aes67_net_set_dscp(int sock, uint8_t dscp);
+
+/**
+ * Set socket send/receive buffer sizes.
+ */
+esp_err_t aes67_net_set_buffers(int sock, int send_size, int recv_size);
+
+/**
+ * Parse an IPv4 address string to network byte order uint32_t.
+ */
+uint32_t aes67_net_ip_to_u32(const char *ip_str);
+
+/**
+ * Format a network byte order IPv4 address to string.
+ */
+void aes67_net_u32_to_ip(uint32_t ip, char *buf, size_t len);
+
+/**
+ * Check if an IP address is multicast (224.0.0.0/4).
+ */
+bool aes67_net_is_multicast(uint32_t ip);
+
+/**
+ * Get the local interface IP address for the default Ethernet interface.
+ */
+esp_err_t aes67_net_get_local_ip(uint32_t *ip);
+
+/**
+ * Get the local MAC address.
+ */
+esp_err_t aes67_net_get_local_mac(uint8_t mac[6]);
+
+/**
+ * Calculate multicast MAC address from multicast IP (IANA mapping).
+ */
+void aes67_net_mcast_ip_to_mac(uint32_t mcast_ip, uint8_t mac[6]);
+
+#ifdef __cplusplus
+}
+#endif

+ 103 - 0
components/aes67/include/aes67_ptp.h

@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * PTP (IEEE 1588) clock synchronization interface.
+ *
+ * Wraps ESP-IDF's PTP daemon and hardware timestamping on ESP32-P4 EMAC.
+ * Provides clock status, grand master tracking, and the sample audio clock
+ * (SAC) used for RTP timestamp generation.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "esp_eth.h"
+#include "aes67_config.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* PTP lock states matching RAVENNA conventions */
+typedef enum {
+    AES67_PTP_UNLOCKED = 0,
+    AES67_PTP_LOCKING  = 1,
+    AES67_PTP_LOCKED   = 2,
+} aes67_ptp_lock_state_t;
+
+/* PTP status information */
+typedef struct {
+    aes67_ptp_lock_state_t lock_state;
+    uint8_t grandmaster_id[8];          /* EUI-64 grand master identity */
+    uint8_t domain;
+    int32_t offset_ns;                  /* Current offset from master in ns */
+    int32_t path_delay_ns;              /* Mean path delay in ns */
+    int32_t jitter_ns;                  /* Clock jitter estimate in ns */
+} aes67_ptp_status_t;
+
+/* Opaque PTP context */
+typedef struct aes67_ptp_ctx *aes67_ptp_handle_t;
+
+/* Callback for PTP lock state changes */
+typedef void (*aes67_ptp_lock_cb_t)(aes67_ptp_lock_state_t state, void *user_data);
+
+/**
+ * Initialize the PTP subsystem. Uses ESP-IDF ptpd and hardware timestamping.
+ */
+esp_err_t aes67_ptp_init(esp_eth_handle_t eth_handle, const aes67_ptp_config_t *config,
+                         aes67_ptp_handle_t *handle);
+
+/**
+ * Start PTP synchronization.
+ */
+esp_err_t aes67_ptp_start(aes67_ptp_handle_t handle);
+
+/**
+ * Stop PTP synchronization.
+ */
+esp_err_t aes67_ptp_stop(aes67_ptp_handle_t handle);
+
+/**
+ * Destroy the PTP context.
+ */
+esp_err_t aes67_ptp_destroy(aes67_ptp_handle_t handle);
+
+/**
+ * Get current PTP status.
+ */
+esp_err_t aes67_ptp_get_status(aes67_ptp_handle_t handle, aes67_ptp_status_t *status);
+
+/**
+ * Get the current PTP time as a 64-bit nanosecond value.
+ */
+esp_err_t aes67_ptp_get_time_ns(aes67_ptp_handle_t handle, uint64_t *time_ns);
+
+/**
+ * Get the current PTP time as seconds + nanoseconds.
+ */
+esp_err_t aes67_ptp_get_time(aes67_ptp_handle_t handle, uint32_t *sec, uint32_t *nsec);
+
+/**
+ * Convert PTP time to an RTP media timestamp at the given sample rate.
+ */
+uint32_t aes67_ptp_time_to_rtp_ts(uint64_t ptp_time_ns, uint32_t sample_rate);
+
+/**
+ * Register a callback for PTP lock state transitions.
+ */
+esp_err_t aes67_ptp_register_lock_cb(aes67_ptp_handle_t handle,
+                                     aes67_ptp_lock_cb_t cb, void *user_data);
+
+/**
+ * Get the grand master ID formatted as a string (e.g. "00-1A-2B-FF-FE-3C-4D-5E").
+ */
+esp_err_t aes67_ptp_get_grandmaster_str(aes67_ptp_handle_t handle, char *buf, size_t len);
+
+#ifdef __cplusplus
+}
+#endif

+ 187 - 0
components/aes67/include/aes67_rtp.h

@@ -0,0 +1,187 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * RTP audio streaming engine.
+ *
+ * Handles RTP packet construction, parsing, transmission and reception
+ * for AES67 audio streams. Manages per-stream jitter buffers allocated
+ * in PSRAM when available.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "aes67_config.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* RTP header as defined in RFC 3550, packed for network wire format */
+typedef struct __attribute__((packed)) {
+    uint8_t  flags;         /* V=2, P, X, CC */
+    uint8_t  pt;            /* M bit + payload type */
+    uint16_t seq;           /* Sequence number (network byte order) */
+    uint32_t timestamp;     /* RTP timestamp (network byte order) */
+    uint32_t ssrc;          /* Synchronization source (network byte order) */
+} aes67_rtp_header_t;
+
+/* Stream direction */
+typedef enum {
+    AES67_STREAM_SOURCE = 0,    /* TX: capture audio, send RTP */
+    AES67_STREAM_SINK   = 1,    /* RX: receive RTP, play audio */
+} aes67_stream_dir_t;
+
+/* Stream status flags (bitmask) */
+#define AES67_RTP_STATUS_SEQ_ERROR      (1 << 0)
+#define AES67_RTP_STATUS_SSRC_ERROR     (1 << 1)
+#define AES67_RTP_STATUS_PT_ERROR       (1 << 2)
+#define AES67_RTP_STATUS_TIMESTAMP_ERR  (1 << 3)
+#define AES67_RTP_STATUS_RECEIVING      (1 << 4)
+#define AES67_RTP_STATUS_MUTED          (1 << 5)
+#define AES67_RTP_STATUS_OVERFLOW       (1 << 6)
+#define AES67_RTP_STATUS_UNDERFLOW      (1 << 7)
+
+/* Configuration for a single RTP stream */
+typedef struct {
+    aes67_stream_dir_t direction;
+    uint32_t dest_ip;               /* Multicast destination IP (network order) */
+    uint16_t dest_port;             /* Destination port (host order) */
+    uint16_t src_port;              /* Source port (host order) */
+    uint8_t  payload_type;          /* RTP payload type (e.g. 98) */
+    uint32_t ssrc;                  /* SSRC identifier */
+    uint32_t sample_rate;           /* Sample rate in Hz */
+    uint8_t  channels;              /* Number of audio channels */
+    uint8_t  word_length;           /* Bytes per sample (2, 3, or 4) */
+    aes67_codec_t codec;            /* Audio codec */
+    uint16_t packet_time_us;        /* Packet time in microseconds */
+    uint32_t playout_delay_us;      /* Playout delay for sinks */
+    uint8_t  dscp;                  /* DSCP marking */
+    uint8_t  ttl;                   /* Multicast TTL */
+    /* Channel routing: maps RTP channel index to local audio channel */
+    uint8_t  routing[CONFIG_AES67_MAX_CHANNELS_PER_STREAM];
+} aes67_rtp_stream_config_t;
+
+/* Runtime stream status */
+typedef struct {
+    uint32_t status_flags;          /* Bitmask of AES67_RTP_STATUS_* */
+    uint32_t packets_sent;          /* Total packets transmitted */
+    uint32_t packets_received;      /* Total packets received */
+    uint32_t packets_lost;          /* Detected packet loss count */
+    uint32_t packets_late;          /* Packets arriving after playout */
+    uint32_t seq_errors;            /* Sequence discontinuity count */
+    int32_t  jitter_us;             /* Estimated jitter in microseconds */
+    uint32_t last_rtp_timestamp;    /* Last RTP timestamp processed */
+    uint16_t last_seq;              /* Last sequence number */
+} aes67_rtp_stream_status_t;
+
+/* Opaque stream handle */
+typedef struct aes67_rtp_stream *aes67_rtp_stream_handle_t;
+
+/* Opaque RTP engine handle */
+typedef struct aes67_rtp_engine *aes67_rtp_engine_handle_t;
+
+/**
+ * Initialize the RTP engine. Creates the network sockets and processing tasks.
+ */
+esp_err_t aes67_rtp_engine_init(const aes67_net_config_t *net_config,
+                                bool use_psram,
+                                aes67_rtp_engine_handle_t *handle);
+
+/**
+ * Start the RTP engine (begins packet processing).
+ */
+esp_err_t aes67_rtp_engine_start(aes67_rtp_engine_handle_t handle);
+
+/**
+ * Stop the RTP engine.
+ */
+esp_err_t aes67_rtp_engine_stop(aes67_rtp_engine_handle_t handle);
+
+/**
+ * Destroy the RTP engine and all streams.
+ */
+esp_err_t aes67_rtp_engine_destroy(aes67_rtp_engine_handle_t handle);
+
+/**
+ * Add an RTP stream (source or sink). Returns a stream handle.
+ */
+esp_err_t aes67_rtp_stream_add(aes67_rtp_engine_handle_t engine,
+                               const aes67_rtp_stream_config_t *config,
+                               aes67_rtp_stream_handle_t *stream);
+
+/**
+ * Remove an RTP stream.
+ */
+esp_err_t aes67_rtp_stream_remove(aes67_rtp_engine_handle_t engine,
+                                  aes67_rtp_stream_handle_t stream);
+
+/**
+ * Get stream status.
+ */
+esp_err_t aes67_rtp_stream_get_status(aes67_rtp_stream_handle_t stream,
+                                      aes67_rtp_stream_status_t *status);
+
+/**
+ * Write audio samples to a source stream's buffer (called by audio driver).
+ * Samples are interleaved, in native byte order, at the stream's word length.
+ *
+ * @param stream        Source stream handle
+ * @param samples       Pointer to interleaved sample data
+ * @param frame_count   Number of audio frames (one sample per channel = one frame)
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_rtp_source_write(aes67_rtp_stream_handle_t stream,
+                                 const void *samples, uint32_t frame_count);
+
+/**
+ * Read audio samples from a sink stream's jitter buffer (called by audio driver).
+ * Returns silence if the jitter buffer is empty or the stream is muted.
+ *
+ * @param stream        Sink stream handle
+ * @param samples       Output buffer for interleaved sample data
+ * @param frame_count   Number of frames to read
+ * @return ESP_OK on success, ESP_ERR_NOT_FOUND if no data available
+ */
+esp_err_t aes67_rtp_sink_read(aes67_rtp_stream_handle_t stream,
+                              void *samples, uint32_t frame_count);
+
+/**
+ * Called by the audio frame timer (TIC) to trigger RTP packet transmission
+ * for all active source streams.
+ */
+esp_err_t aes67_rtp_engine_process_tx(aes67_rtp_engine_handle_t handle,
+                                      uint32_t rtp_timestamp);
+
+/**
+ * Get the stream buffer used for RTP RX -> playback decoupling.
+ */
+#include "freertos/stream_buffer.h"
+StreamBufferHandle_t aes67_rtp_engine_get_stream_buf(aes67_rtp_engine_handle_t handle);
+
+/**
+ * Set the task to notify when new sink data arrives.
+ */
+#include "freertos/task.h"
+void aes67_rtp_engine_set_notify_task(aes67_rtp_engine_handle_t handle,
+                                      TaskHandle_t task);
+
+/**
+ * Enable/disable direct I2S playback from the RTP RX path.
+ */
+void aes67_rtp_engine_set_playback(aes67_rtp_engine_handle_t handle,
+                                   void *audio_handle);
+
+/**
+ * Get available frames in a sink stream's jitter buffer.
+ */
+uint32_t aes67_rtp_sink_available(aes67_rtp_stream_handle_t stream);
+
+#ifdef __cplusplus
+}
+#endif

+ 99 - 0
components/aes67/include/aes67_sap.h

@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * SAP (Session Announcement Protocol) - RFC 2974
+ *
+ * Handles periodic multicast announcement and discovery of AES67
+ * audio sessions. Each source stream is advertised with its SDP.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* SAP multicast address and port per RFC 2974 */
+#define AES67_SAP_MCAST_ADDR   "239.255.255.255"
+#define AES67_SAP_PORT         9875
+#define AES67_SAP_MAX_SDP_LEN  1024
+
+/* Discovered remote source */
+typedef struct {
+    uint32_t origin_ip;                 /* IP of announcing node */
+    uint16_t msg_id;                    /* SAP message ID hash */
+    char name[64];                      /* Session name from SDP */
+    char sdp[AES67_SAP_MAX_SDP_LEN];   /* Full SDP text */
+    uint32_t last_seen;                 /* Tick count of last announcement */
+} aes67_sap_remote_source_t;
+
+/* Callback when a remote source is discovered or lost */
+typedef void (*aes67_sap_event_cb_t)(bool is_announce,
+                                     const aes67_sap_remote_source_t *source,
+                                     void *user_data);
+
+/* Opaque SAP context */
+typedef struct aes67_sap_ctx *aes67_sap_handle_t;
+
+/**
+ * Initialize the SAP subsystem.
+ */
+esp_err_t aes67_sap_init(aes67_sap_handle_t *handle);
+
+/**
+ * Start SAP announcements and listening.
+ */
+esp_err_t aes67_sap_start(aes67_sap_handle_t handle);
+
+/**
+ * Stop SAP.
+ */
+esp_err_t aes67_sap_stop(aes67_sap_handle_t handle);
+
+/**
+ * Destroy SAP context.
+ */
+esp_err_t aes67_sap_destroy(aes67_sap_handle_t handle);
+
+/**
+ * Announce a local source. The SDP will be periodically multicast.
+ */
+esp_err_t aes67_sap_announce(aes67_sap_handle_t handle,
+                             uint16_t msg_id, uint32_t origin_ip,
+                             const char *sdp);
+
+/**
+ * Send a deletion for a previously announced source.
+ */
+esp_err_t aes67_sap_delete(aes67_sap_handle_t handle,
+                           uint16_t msg_id, uint32_t origin_ip,
+                           const char *sdp);
+
+/**
+ * Register callback for remote source discovery/loss events.
+ */
+esp_err_t aes67_sap_register_cb(aes67_sap_handle_t handle,
+                                aes67_sap_event_cb_t cb, void *user_data);
+
+/**
+ * Get the number of currently known remote sources.
+ */
+int aes67_sap_get_remote_count(aes67_sap_handle_t handle);
+
+/**
+ * Get a remote source by index (0-based). Returns ESP_ERR_NOT_FOUND if
+ * the index is out of range.
+ */
+esp_err_t aes67_sap_get_remote(aes67_sap_handle_t handle, int index,
+                               aes67_sap_remote_source_t *source);
+
+#ifdef __cplusplus
+}
+#endif

+ 104 - 0
components/aes67/include/aes67_sdp.h

@@ -0,0 +1,104 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * SDP (Session Description Protocol) parser and generator for AES67.
+ *
+ * Generates and parses SDP documents conforming to AES67 / RAVENNA
+ * requirements, including PTP reference clock attributes.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "aes67_config.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define AES67_SDP_MAX_LEN  1024
+
+/* Parsed SDP session description */
+typedef struct {
+    /* Origin (o= line) */
+    char origin_username[32];
+    uint32_t session_id;
+    uint32_t session_version;
+    uint32_t origin_ip;
+
+    /* Session name (s= line) */
+    char session_name[64];
+
+    /* Connection (c= line) */
+    uint32_t connection_ip;             /* Multicast group address */
+    uint8_t ttl;
+
+    /* Media (m= line) */
+    uint16_t port;
+    uint8_t payload_type;
+
+    /* Audio parameters (from a=rtpmap) */
+    aes67_codec_t codec;
+    uint32_t sample_rate;
+    uint8_t channels;
+    uint8_t word_length;
+
+    /* Packet time (a=ptime) */
+    uint16_t ptime_us;                  /* Packet time in microseconds */
+
+    /* PTP reference clock (a=ts-refclk) */
+    bool has_ptp_refclk;
+    uint8_t ptp_grandmaster_id[8];
+    uint8_t ptp_domain;
+    bool ptp_traceable;
+
+    /* Media clock offset (a=mediaclk) */
+    bool has_mediaclk;
+    uint32_t mediaclk_offset;
+
+    /* Stream direction for SDP attribute (a=sendonly / a=recvonly) */
+    bool is_source;             /* true = sendonly, false = recvonly */
+} aes67_sdp_t;
+
+/**
+ * Generate an AES67-compliant SDP string.
+ *
+ * @param sdp       Populated SDP structure
+ * @param buf       Output buffer for SDP text
+ * @param buf_len   Size of output buffer
+ * @return Number of bytes written, or -1 on error
+ */
+int aes67_sdp_generate(const aes67_sdp_t *sdp, char *buf, size_t buf_len);
+
+/**
+ * Parse an SDP string into an aes67_sdp_t structure.
+ *
+ * @param sdp_text  Null-terminated SDP text
+ * @param sdp       Output structure
+ * @return ESP_OK on success, ESP_ERR_INVALID_ARG on parse failure
+ */
+esp_err_t aes67_sdp_parse(const char *sdp_text, aes67_sdp_t *sdp);
+
+/**
+ * Get the codec name string for a given codec enum.
+ */
+const char *aes67_codec_to_str(aes67_codec_t codec);
+
+/**
+ * Parse a codec name string to enum. Returns -1 on unknown codec.
+ */
+int aes67_codec_from_str(const char *name);
+
+/**
+ * Get word length (bytes per sample) for a codec.
+ */
+uint8_t aes67_codec_word_length(aes67_codec_t codec);
+
+#ifdef __cplusplus
+}
+#endif

+ 140 - 0
components/aes67/include/aes67_session.h

@@ -0,0 +1,140 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Session manager for AES67 source and sink streams.
+ *
+ * Orchestrates stream lifecycle: creating/removing sources and sinks,
+ * generating SDP, managing SAP announcements, and mapping between
+ * RTP streams and the local audio I/O.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+#include "aes67_config.h"
+#include "aes67_rtp.h"
+#include "aes67_sdp.h"
+#include "aes67_ptp.h"
+#include "aes67_sap.h"
+#include "aes67_audio.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Source stream descriptor */
+typedef struct {
+    uint8_t id;
+    char name[64];
+    aes67_rtp_stream_config_t rtp_config;
+    aes67_sdp_t sdp;
+    aes67_rtp_stream_handle_t rtp_stream;
+    uint16_t sap_msg_id;    /* CRC16 hash used for SAP announce/delete matching */
+    bool enabled;
+} aes67_source_t;
+
+/* Sink stream descriptor */
+typedef struct {
+    uint8_t id;
+    char name[64];
+    aes67_rtp_stream_config_t rtp_config;
+    aes67_sdp_t sdp;
+    aes67_rtp_stream_handle_t rtp_stream;
+    bool enabled;
+    char remote_sdp[AES67_SDP_MAX_LEN];
+} aes67_sink_t;
+
+/* Session manager context (opaque) */
+typedef struct aes67_session_mgr *aes67_session_handle_t;
+
+/**
+ * Initialize the session manager with references to all subsystems.
+ */
+esp_err_t aes67_session_init(aes67_rtp_engine_handle_t rtp,
+                             aes67_ptp_handle_t ptp,
+                             aes67_sap_handle_t sap,
+                             aes67_audio_handle_t audio,
+                             const aes67_config_t *config,
+                             aes67_session_handle_t *handle);
+
+/**
+ * Create a source stream. Generates SDP, adds RTP stream, and optionally
+ * starts SAP announcements.
+ *
+ * @param handle    Session manager
+ * @param name      Stream name (used in SDP s= line)
+ * @param channels  Number of channels (0 = use default)
+ * @param codec     Audio codec (or -1 for default)
+ * @param id        Output: assigned stream ID
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_session_add_source(aes67_session_handle_t handle,
+                                   const char *name,
+                                   uint8_t channels,
+                                   aes67_codec_t codec,
+                                   uint8_t *id);
+
+/**
+ * Remove a source stream by ID.
+ */
+esp_err_t aes67_session_remove_source(aes67_session_handle_t handle, uint8_t id);
+
+/**
+ * Create a sink stream from an SDP description (e.g. from SAP discovery).
+ *
+ * @param handle    Session manager
+ * @param name      Local name for this sink
+ * @param sdp_text  SDP of the remote source to subscribe to
+ * @param id        Output: assigned stream ID
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_session_add_sink(aes67_session_handle_t handle,
+                                 const char *name,
+                                 const char *sdp_text,
+                                 uint8_t *id);
+
+/**
+ * Remove a sink stream by ID.
+ */
+esp_err_t aes67_session_remove_sink(aes67_session_handle_t handle, uint8_t id);
+
+/**
+ * Get a source descriptor by ID. Returns ESP_ERR_NOT_FOUND if not active.
+ */
+esp_err_t aes67_session_get_source(aes67_session_handle_t handle, uint8_t id,
+                                   aes67_source_t *source);
+
+/**
+ * Get a sink descriptor by ID.
+ */
+esp_err_t aes67_session_get_sink(aes67_session_handle_t handle, uint8_t id,
+                                 aes67_sink_t *sink);
+
+/**
+ * Get the number of active sources.
+ */
+int aes67_session_get_source_count(aes67_session_handle_t handle);
+
+/**
+ * Get the number of active sinks.
+ */
+int aes67_session_get_sink_count(aes67_session_handle_t handle);
+
+/**
+ * Set the packet time for new source streams (does not affect existing).
+ */
+void aes67_session_set_packet_time(aes67_session_handle_t handle, uint16_t ptime_us);
+
+/**
+ * Destroy session manager and all streams.
+ */
+esp_err_t aes67_session_destroy(aes67_session_handle_t handle);
+
+#ifdef __cplusplus
+}
+#endif

+ 50 - 0
components/aes67/include/aes67_webui.h

@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67 Web UI - HTTP server and WebSocket backend.
+ *
+ * Serves an embedded SPA, exposes REST endpoints for device status
+ * and stream management, and pushes real-time status updates over
+ * a WebSocket connection at 2Hz.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include "esp_err.h"
+#include "aes67.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Opaque Web UI context */
+typedef struct aes67_webui *aes67_webui_handle_t;
+
+/**
+ * Initialize the Web UI server. Does not start listening yet.
+ *
+ * @param node   Running AES67 node instance
+ * @param port   HTTP port (e.g. 80)
+ * @param handle Output handle for the created context
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_webui_init(aes67_node_handle_t node, uint16_t port,
+                            aes67_webui_handle_t *handle);
+
+/**
+ * Start the HTTP server and begin accepting connections.
+ */
+esp_err_t aes67_webui_start(aes67_webui_handle_t handle);
+
+/**
+ * Stop the HTTP server and tear down the WebSocket push task.
+ */
+esp_err_t aes67_webui_stop(aes67_webui_handle_t handle);
+
+#ifdef __cplusplus
+}
+#endif

+ 53 - 0
components/aes67/private_include/aes67_hw_timer.h

@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Hardware PTP-synchronized audio frame timer.
+ *
+ * Uses the EMAC target time interrupt to fire a callback at precise
+ * PTP-synchronized intervals, replacing software-based timing with
+ * hardware-precision audio frame boundaries.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Callback fired at each audio frame boundary from ISR context.
+ * Must be IRAM_ATTR and as brief as possible - typically just
+ * gives a semaphore or sets a flag. */
+typedef void (*aes67_hw_timer_cb_t)(void *user_data);
+
+/**
+ * Initialize the hardware PTP frame timer.
+ *
+ * @param frame_period_ns  Frame period in nanoseconds (e.g. 1000000 for 1ms)
+ * @param cb               Callback to fire at each frame boundary
+ * @param user_data        User data passed to callback
+ * @return ESP_OK on success
+ */
+esp_err_t aes67_hw_timer_init(uint32_t frame_period_ns,
+                               aes67_hw_timer_cb_t cb, void *user_data);
+
+/**
+ * Start the hardware frame timer. Programs the first target time
+ * aligned to the next PTP-time frame boundary.
+ */
+esp_err_t aes67_hw_timer_start(void);
+
+/**
+ * Stop the hardware frame timer.
+ */
+esp_err_t aes67_hw_timer_stop(void);
+
+#ifdef __cplusplus
+}
+#endif

+ 662 - 0
components/aes67/src/aes67.c

@@ -0,0 +1,662 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67/RAVENNA node lifecycle management.
+ *
+ * Coordinates initialization, startup and teardown of all AES67
+ * subsystems: PTP, RTP engine, SAP discovery, audio I/O and the
+ * session manager that ties them together.
+ */
+
+#include "aes67.h"
+#include "aes67_config.h"
+#include "aes67_ptp.h"
+#include "aes67_rtp.h"
+#include "aes67_sap.h"
+#include "aes67_audio.h"
+#include "aes67_session.h"
+#include "aes67_hw_timer.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include "esp_log.h"
+#include "esp_event.h"
+#include "esp_heap_caps.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "freertos/task.h"
+#include "freertos/stream_buffer.h"
+#include "driver/i2s_std.h"
+
+static const char *TAG = "aes67";
+
+/* mDNS init/deinit -- declared here since aes67_mdns.c has no header */
+esp_err_t aes67_mdns_init(const char *node_name, uint16_t rtp_port);
+void aes67_mdns_deinit(void);
+
+/* Define the event base for the ESP event loop */
+ESP_EVENT_DEFINE_BASE(AES67_EVENT);
+
+/* Semaphore signaled by the hardware PTP timer ISR */
+static SemaphoreHandle_t s_frame_sem = NULL;
+
+/* Internal node structure holding all subsystem handles */
+struct aes67_node {
+    aes67_config_t              config;
+    aes67_ptp_handle_t          ptp;
+    aes67_rtp_engine_handle_t   rtp;
+    aes67_sap_handle_t          sap;
+    aes67_audio_handle_t        audio;
+    aes67_session_handle_t      session;
+    TaskHandle_t                tx_task;
+    StreamBufferHandle_t        external_playout_buf;  /* optional, see aes67_node_set_playout_buf */
+    bool                        running;
+};
+
+/* Global node pointer for the ISR callback (single instance) */
+static aes67_node_handle_t s_node = NULL;
+
+/*
+ * Hardware PTP timer ISR callback. Fires at exact PTP-clock-aligned
+ * audio frame boundaries. Gives the semaphore to wake the TX task.
+ */
+static void IRAM_ATTR hw_frame_isr(void *user_data)
+{
+    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
+    xSemaphoreGiveFromISR(s_frame_sem, &xHigherPriorityTaskWoken);
+    if (xHigherPriorityTaskWoken) {
+        portYIELD_FROM_ISR();
+    }
+}
+
+/*
+ * Unified audio frame task (TIC). Driven by the hardware PTP timer ISR,
+ * this task handles both TX and RX audio processing at PTP-aligned
+ * frame boundaries:
+ *
+ * TX path: Read from source ring buffers -> convert -> send RTP packets
+ * RX path: Read from sink jitter buffers -> write to I2S playback
+ *
+ * This ensures both directions are synchronized to the PTP clock with
+ * hardware-precision timing.
+ */
+static void audio_frame_task(void *arg)
+{
+    aes67_node_handle_t node = (aes67_node_handle_t)arg;
+    uint32_t frame_count = 0;
+
+    /* Pre-allocate playback buffer for sink -> I2S path */
+    uint32_t spp = (node->config.audio.sample_rate *
+                     node->config.audio.packet_time_us) / 1000000;
+    int32_t *playback_buf = heap_caps_malloc(
+        spp * node->config.audio.channels * sizeof(int32_t),
+        MALLOC_CAP_INTERNAL);
+
+    /* Capture buffer for I2S RX -> source stream feeding */
+    StreamBufferHandle_t cap_buf = aes67_audio_get_capture_buf(node->audio);
+    int32_t *cap_samples = NULL;
+    if (cap_buf) {
+        cap_samples = heap_caps_malloc(
+            spp * node->config.audio.channels * sizeof(int32_t),
+            MALLOC_CAP_INTERNAL);
+    }
+
+    ESP_LOGI(TAG, "Audio frame task started (hw-timed TX%s)",
+             cap_buf ? "+capture" : "");
+
+    while (node->running) {
+        /* Wait for the hardware PTP frame timer ISR */
+        if (xSemaphoreTake(s_frame_sem, pdMS_TO_TICKS(10)) != pdTRUE) {
+            continue;
+        }
+
+        frame_count++;
+
+        /* Get current PTP time for RTP timestamp generation */
+        uint64_t ptp_time_ns;
+        aes67_ptp_get_time_ns(node->ptp, &ptp_time_ns);
+        uint32_t rtp_ts = aes67_ptp_time_to_rtp_ts(
+            ptp_time_ns, node->config.audio.sample_rate);
+
+        /* Feed captured I2S audio into source stream ring buffers.
+         * The RX DMA ISR writes captured int32 samples to cap_buf.
+         * We read one packet worth and write to all active sources. */
+        if (cap_buf && cap_samples) {
+            uint32_t need = spp * node->config.audio.channels * sizeof(int32_t);
+            size_t got = xStreamBufferReceive(cap_buf, cap_samples, need, 0);
+            if (got >= need) {
+                /* Write captured audio to all active source streams */
+                for (int i = 0; i < CONFIG_AES67_MAX_SOURCES; i++) {
+                    aes67_source_t src;
+                    if (aes67_session_get_source(node->session, i, &src) == ESP_OK &&
+                        src.enabled && src.rtp_stream) {
+                        aes67_rtp_source_write(src.rtp_stream, cap_samples, spp);
+                    }
+                }
+            }
+        }
+
+        /* TX: Process all active source streams - read from ring buffers,
+         * convert to network format, send RTP packets */
+        aes67_rtp_engine_process_tx(node->rtp, rtp_ts);
+    }
+
+    ESP_LOGI(TAG, "Audio frame task stopped");
+    vTaskDelete(NULL);
+}
+
+/*
+ * Dedicated playback task. Runs in a tight loop reading from the
+ * first active sink's jitter buffer and writing to I2S.
+ * i2s_channel_write blocks until DMA has room, which naturally
+ * paces output at 48kHz. This task is completely decoupled from
+ * both the RTP RX task and the TIC task.
+ */
+static void playback_task(void *arg)
+{
+    aes67_node_handle_t node = (aes67_node_handle_t)arg;
+    const uint8_t ch = node->config.audio.channels;
+
+    /* Playback buffer: holds up to 768 frames (16ms at 48kHz = 4 DMA descriptors).
+     * Reading multiple packets per i2s_channel_write call amortizes the ~0.4ms
+     * overhead of xStreamBufferReceive + context switches. Without this, the
+     * per-iteration overhead causes ~10% throughput loss. */
+    const uint32_t max_frames = 768;
+    const uint32_t buf_bytes = max_frames * ch * sizeof(int32_t);
+    int32_t *pcm_buf = heap_caps_malloc(buf_bytes, MALLOC_CAP_INTERNAL);
+    if (!pcm_buf) {
+        ESP_LOGE(TAG, "Playback task: alloc failed");
+        vTaskDelete(NULL);
+        return;
+    }
+
+    ESP_LOGI(TAG, "Playback task started (ch=%u, DMA-paced, buf=%lu bytes)",
+             ch, (unsigned long)buf_bytes);
+
+    uint32_t total_frames = 0;
+    uint32_t write_count = 0;
+    bool sink_found = false;
+
+    while (node->running) {
+        /* Wait for a sink to be added */
+        if (!sink_found) {
+            int sink_count = aes67_session_get_sink_count(node->session);
+            for (int s = 0; s < sink_count && s < CONFIG_AES67_MAX_SINKS; s++) {
+                aes67_sink_t sink;
+                if (aes67_session_get_sink(node->session, s, &sink) == ESP_OK &&
+                    sink.enabled && sink.rtp_stream) {
+                    sink_found = true;
+                    ESP_LOGI(TAG, "Playback: locked to sink '%s' "
+                             "(ch=%u, wl=%u, rate=%lu, ptime=%lu us)",
+                             sink.name, sink.rtp_config.channels,
+                             sink.rtp_config.word_length,
+                             (unsigned long)sink.rtp_config.sample_rate,
+                             (unsigned long)sink.rtp_config.packet_time_us);
+                    break;
+                }
+            }
+            if (!sink_found) {
+                vTaskDelay(pdMS_TO_TICKS(100));
+                continue;
+            }
+        }
+
+        /* Get the stream buffer: the optional external feed (e.g. an Rx-hook
+         * integration) if one was registered, otherwise the RTP engine's. */
+        StreamBufferHandle_t sbuf = node->external_playout_buf ?
+                                     node->external_playout_buf :
+                                     aes67_rtp_engine_get_stream_buf(node->rtp);
+        if (!sbuf) {
+            vTaskDelay(pdMS_TO_TICKS(100));
+            continue;
+        }
+
+        size_t frame_align = ch * sizeof(int32_t);
+        if (!sink_found) {
+            continue;
+        }
+
+        /* Two-stage read: first blocking wait for at least one packet,
+         * then immediately drain ALL accumulated data non-blocking.
+         * This batches multiple packets into one i2s_channel_write call,
+         * amortizing the ~0.4ms task scheduling overhead that otherwise
+         * causes 10% throughput loss (4ms DMA cycle + 0.4ms overhead). */
+        size_t received = xStreamBufferReceive(sbuf, pcm_buf, frame_align,
+                                                pdMS_TO_TICKS(20));
+        if (received < frame_align) {
+            /* Stream stopped: flush silence through all DMA descriptors.
+             * With auto_clear=false, old data replays unless we push zeros. */
+            i2s_chan_handle_t tx = aes67_audio_get_tx_chan(node->audio);
+            if (tx && node->config.output_mode == AES67_OUTPUT_I2S) {
+                memset(pcm_buf, 0, buf_bytes);
+                for (int d = 0; d < 4; d++) {
+                    size_t written = 0;
+                    i2s_channel_write(tx, pcm_buf, 192 * frame_align,
+                                      &written, pdMS_TO_TICKS(10));
+                }
+            }
+            continue;
+        }
+        /* Drain all remaining data immediately (non-blocking) */
+        if (received < buf_bytes) {
+            size_t extra = xStreamBufferReceive(sbuf, (uint8_t *)pcm_buf + received,
+                                                buf_bytes - received, 0);
+            received += extra;
+        }
+
+        /* Align to frame boundary */
+        uint32_t frames = received / frame_align;
+        uint32_t aligned_bytes = frames * frame_align;
+
+        /* Route to output */
+        switch (node->config.output_mode) {
+        case AES67_OUTPUT_I2S: {
+            i2s_chan_handle_t tx = aes67_audio_get_tx_chan(node->audio);
+            if (tx) {
+                size_t written = 0;
+                i2s_channel_write(tx, pcm_buf, aligned_bytes,
+                                  &written, portMAX_DELAY);
+            }
+            break;
+        }
+        case AES67_OUTPUT_CALLBACK:
+            if (node->config.audio_cb) {
+                node->config.audio_cb(pcm_buf, frames, ch,
+                                      node->config.audio.word_length,
+                                      node->config.audio.sample_rate,
+                                      node->config.audio_cb_user_data);
+            }
+            break;
+        case AES67_OUTPUT_STREAM_BUFFER:
+            /* Data stays in stream buffer for app consumption */
+            break;
+        }
+
+        total_frames += frames;
+        write_count++;
+
+        if ((write_count % 1000) == 0) {
+            static int64_t last_stat_us = 0;
+            static uint32_t last_stat_frames = 0;
+            int64_t now = esp_timer_get_time();
+            if (last_stat_us > 0) {
+                int64_t delta_us = now - last_stat_us;
+                uint32_t delta_frames = total_frames - last_stat_frames;
+                if (delta_us > 0) {
+                    ESP_LOGI(TAG, "PB: %lu fps, sb=%u",
+                             (unsigned long)(delta_frames * 1000000ULL / delta_us),
+                             (unsigned)xStreamBufferBytesAvailable(sbuf));
+                }
+            }
+            last_stat_us = now;
+            last_stat_frames = total_frames;
+        }
+    }
+
+    heap_caps_free(pcm_buf);
+    ESP_LOGI(TAG, "Playback task stopped");
+    vTaskDelete(NULL);
+}
+
+/*
+ * Audio frame callback from I2S DMA (unused - kept for API compat).
+ */
+static void audio_frame_callback(uint32_t frame_count, void *user_data)
+{
+}
+
+esp_err_t aes67_node_init(const aes67_config_t *config, aes67_node_handle_t *handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_node *node = calloc(1, sizeof(struct aes67_node));
+    if (!node) {
+        ESP_LOGE(TAG, "failed to allocate node context");
+        return ESP_ERR_NO_MEM;
+    }
+
+    /* Apply caller config or fall back to Kconfig defaults */
+    if (config) {
+        memcpy(&node->config, config, sizeof(aes67_config_t));
+    } else {
+        aes67_config_t defaults = AES67_CONFIG_DEFAULT();
+        memcpy(&node->config, &defaults, sizeof(aes67_config_t));
+    }
+
+    esp_err_t ret;
+
+    /* 1. PTP clock subsystem */
+    ret = aes67_ptp_init(node->config.net.eth_handle, &node->config.ptp, &node->ptp);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "PTP init failed: %s", esp_err_to_name(ret));
+        goto fail_ptp;
+    }
+
+    /* 2. RTP packet engine */
+    ret = aes67_rtp_engine_init(&node->config.net, node->config.use_psram, &node->rtp);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "RTP engine init failed: %s", esp_err_to_name(ret));
+        goto fail_rtp;
+    }
+
+    /* 3. SAP discovery (optional) */
+    if (node->config.sap_enabled) {
+        ret = aes67_sap_init(&node->sap);
+        if (ret != ESP_OK) {
+            ESP_LOGE(TAG, "SAP init failed: %s", esp_err_to_name(ret));
+            goto fail_sap;
+        }
+    }
+
+    /* 4. Audio I/O driver (I2S + DMA) */
+    ret = aes67_audio_init(&node->config.audio, &node->config.i2s_pins,
+                           node->config.use_psram, node->config.ring_buffer_ms,
+                           &node->audio);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "audio init failed: %s", esp_err_to_name(ret));
+        goto fail_audio;
+    }
+
+    /* 5. Session manager -- connects all subsystems */
+    ret = aes67_session_init(node->rtp, node->ptp, node->sap,
+                             node->audio, &node->config, &node->session);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "session manager init failed: %s", esp_err_to_name(ret));
+        goto fail_session;
+    }
+
+    /* 6. Register the audio frame callback (for I2S DMA capture handling) */
+    ret = aes67_audio_register_frame_cb(node->audio, audio_frame_callback, node);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "audio frame callback registration failed: %s",
+                 esp_err_to_name(ret));
+        goto fail_cb;
+    }
+
+    /* 7. Initialize hardware PTP frame timer for RTP TX.
+     * frame_period = samples_per_packet / sample_rate in nanoseconds */
+    uint32_t samples_per_pkt = (node->config.audio.sample_rate *
+                                 node->config.audio.packet_time_us) / 1000000;
+    uint32_t frame_ns = (uint32_t)((uint64_t)samples_per_pkt * 1000000000ULL /
+                                    node->config.audio.sample_rate);
+
+    /* Create the frame semaphore for ISR -> task signaling */
+    s_frame_sem = xSemaphoreCreateBinary();
+    if (!s_frame_sem) {
+        ESP_LOGE(TAG, "failed to create frame semaphore");
+        goto fail_cb;
+    }
+
+    ret = aes67_hw_timer_init(frame_ns, hw_frame_isr, NULL);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "hw PTP timer init failed: %s (falling back to I2S timing)",
+                 esp_err_to_name(ret));
+        /* Non-fatal: I2S DMA callback still works as fallback */
+    }
+
+    s_node = node;
+    node->running = false;
+    *handle = node;
+
+    ESP_LOGI(TAG, "node \"%s\" initialized (v%d.%d.%d)",
+             node->config.node_name,
+             AES67_VERSION_MAJOR, AES67_VERSION_MINOR, AES67_VERSION_PATCH);
+
+    return ESP_OK;
+
+fail_cb:
+    aes67_session_destroy(node->session);
+fail_session:
+    aes67_audio_destroy(node->audio);
+fail_audio:
+    if (node->config.sap_enabled && node->sap) {
+        aes67_sap_destroy(node->sap);
+    }
+fail_sap:
+    aes67_rtp_engine_destroy(node->rtp);
+fail_rtp:
+    aes67_ptp_destroy(node->ptp);
+fail_ptp:
+    free(node);
+    return ret;
+}
+
+esp_err_t aes67_node_start(aes67_node_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (handle->running) {
+        ESP_LOGW(TAG, "node already running");
+        return ESP_OK;
+    }
+
+    esp_err_t ret;
+
+    /* Start subsystems in dependency order */
+    ret = aes67_ptp_start(handle->ptp);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "PTP start failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    ret = aes67_rtp_engine_start(handle->rtp);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "RTP engine start failed: %s", esp_err_to_name(ret));
+        aes67_ptp_stop(handle->ptp);
+        return ret;
+    }
+
+    if (handle->config.sap_enabled && handle->sap) {
+        ret = aes67_sap_start(handle->sap);
+        if (ret != ESP_OK) {
+            ESP_LOGE(TAG, "SAP start failed: %s", esp_err_to_name(ret));
+            aes67_rtp_engine_stop(handle->rtp);
+            aes67_ptp_stop(handle->ptp);
+            return ret;
+        }
+    }
+
+    ret = aes67_audio_start(handle->audio);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "audio start failed: %s", esp_err_to_name(ret));
+        if (handle->config.sap_enabled && handle->sap) {
+            aes67_sap_stop(handle->sap);
+        }
+        aes67_rtp_engine_stop(handle->rtp);
+        aes67_ptp_stop(handle->ptp);
+        return ret;
+    }
+
+    /* mDNS advertisement (optional) */
+    if (handle->config.mdns_enabled) {
+        ret = aes67_mdns_init(handle->config.node_name, handle->config.net.rtp_port);
+        if (ret != ESP_OK) {
+            ESP_LOGW(TAG, "mDNS init failed: %s (continuing without mDNS)",
+                     esp_err_to_name(ret));
+            /* Non-fatal: the node still works without mDNS */
+        }
+    }
+
+    handle->running = true;
+
+    /* Enable direct RTP RX -> staging ring path */
+    aes67_rtp_engine_set_playback(handle->rtp, handle->audio);
+    ESP_LOGI(TAG, "Direct RTP->I2S path enabled (zero-buffer)");
+
+    /* Playback task: only needed for callback/stream_buffer output modes.
+     * For I2S mode, the DMA ISR reads directly from the stream buffer,
+     * bypassing the playback task entirely (zero scheduling overhead). */
+    if (handle->config.output_mode != AES67_OUTPUT_I2S) {
+        static TaskHandle_t pb_task = NULL;
+        xTaskCreatePinnedToCore(playback_task, "aes67_pb", 8192, handle,
+                                21, &pb_task, 0);
+        aes67_rtp_engine_set_notify_task(handle->rtp, pb_task);
+        ESP_LOGI(TAG, "Playback task started (output_mode=%d)", handle->config.output_mode);
+    } else {
+        ESP_LOGI(TAG, "I2S output via DMA ISR (no playback task needed)");
+    }
+
+    /* Start the hardware PTP frame timer and audio frame task.
+     * This task handles both TX and RX at PTP-aligned boundaries. */
+    ret = aes67_hw_timer_start();
+    if (ret == ESP_OK) {
+        BaseType_t xret = xTaskCreatePinnedToCore(
+            audio_frame_task, "aes67_tic", 8192, handle,
+            21,     /* High priority, just below audio I/O (22) */
+            &handle->tx_task,
+            0       /* Pin to core 0 (PTP/network core) */
+        );
+        if (xret != pdPASS) {
+            ESP_LOGW(TAG, "failed to create audio frame task");
+            aes67_hw_timer_stop();
+        } else {
+            ESP_LOGI(TAG, "Audio frame processing driven by hardware PTP timer");
+        }
+    } else {
+        ESP_LOGW(TAG, "hw timer start failed, using I2S DMA callback");
+    }
+
+    ESP_LOGI(TAG, "node started");
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_stop(aes67_node_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (!handle->running) {
+        ESP_LOGW(TAG, "node not running");
+        return ESP_OK;
+    }
+
+    /* Tear down in reverse order */
+    if (handle->config.mdns_enabled) {
+        aes67_mdns_deinit();
+    }
+
+    aes67_audio_stop(handle->audio);
+
+    if (handle->config.sap_enabled && handle->sap) {
+        aes67_sap_stop(handle->sap);
+    }
+
+    aes67_rtp_engine_stop(handle->rtp);
+    aes67_ptp_stop(handle->ptp);
+
+    handle->running = false;
+    ESP_LOGI(TAG, "node stopped");
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_destroy(aes67_node_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    /* Stop first if still running */
+    if (handle->running) {
+        aes67_node_stop(handle);
+    }
+
+    /* Destroy subsystems in reverse init order */
+    aes67_session_destroy(handle->session);
+    aes67_audio_destroy(handle->audio);
+
+    if (handle->config.sap_enabled && handle->sap) {
+        aes67_sap_destroy(handle->sap);
+    }
+
+    aes67_rtp_engine_destroy(handle->rtp);
+    aes67_ptp_destroy(handle->ptp);
+
+    ESP_LOGI(TAG, "node \"%s\" destroyed", handle->config.node_name);
+    free(handle);
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_config(aes67_node_handle_t handle, aes67_config_t *config)
+{
+    if (!handle || !config) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    memcpy(config, &handle->config, sizeof(aes67_config_t));
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_session(aes67_node_handle_t handle,
+                                  aes67_session_handle_t *session)
+{
+    if (!handle || !session) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    *session = handle->session;
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_audio(aes67_node_handle_t handle,
+                               aes67_audio_handle_t *audio)
+{
+    if (!handle || !audio) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    *audio = handle->audio;
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_sap(aes67_node_handle_t handle,
+                              aes67_sap_handle_t *sap)
+{
+    if (!handle || !sap) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    *sap = handle->sap;
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_ptp(aes67_node_handle_t handle,
+                              aes67_ptp_handle_t *ptp)
+{
+    if (!handle || !ptp) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    *ptp = handle->ptp;
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_get_rtp(aes67_node_handle_t handle,
+                              aes67_rtp_engine_handle_t *rtp)
+{
+    if (!handle || !rtp) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    *rtp = handle->rtp;
+    return ESP_OK;
+}
+
+esp_err_t aes67_node_set_playout_buf(aes67_node_handle_t handle,
+                                      StreamBufferHandle_t sbuf)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    handle->external_playout_buf = sbuf;
+    return ESP_OK;
+}

+ 990 - 0
components/aes67/src/aes67_audio.c

@@ -0,0 +1,990 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * I2S audio driver for AES67 on ESP32-P4.
+ *
+ * Uses ESP-IDF I2S standard mode with DMA for audio capture and playback.
+ * Ring buffers bridge between I2S DMA transfers and the RTP engine.
+ * DMA buffers reside in internal SRAM; ring buffers may use PSRAM.
+ */
+
+#include "aes67_audio.h"
+#include <string.h>
+#include "esp_log.h"
+#include "esp_heap_caps.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "driver/i2s_std.h"
+#include "driver/i2s_common.h"
+#include "esp_timer.h"
+#include "esp_cache.h"
+#include "hal/i2s_ll.h"
+#include "hal/hal_utils.h"
+#include "soc/hp_sys_clkrst_struct.h"
+
+static const char *TAG = "aes67_audio";
+
+/* Kept for future capture (RX) task */
+
+/* I2S DMA: 2 descriptors at 16 frames (0.33ms) each.
+ * ISR fires at 3000Hz. DMA ring latency = 0.33ms.
+ * Stable at all ptimes (0.125ms to 4ms) with 100% utilization.
+ * DMA_FRAME_NUM=6 (8000Hz ISR) causes 0.03% loss at the edge. */
+#define DMA_DESC_NUM            2
+#define DMA_FRAME_NUM           16
+
+
+struct aes67_audio_ctx {
+    i2s_chan_handle_t tx_chan;
+    i2s_chan_handle_t rx_chan;
+    aes67_audio_config_t config;
+
+    /* Ring buffers (PSRAM when use_psram is set) */
+    int32_t *capture_buf;
+    int32_t *playback_buf;
+    uint32_t buf_size_frames;
+    volatile uint32_t capture_wr;
+    volatile uint32_t capture_rd;
+    volatile uint32_t playback_wr;
+    volatile uint32_t playback_rd;
+
+    /* DMA staging buffers (always internal SRAM) */
+    uint8_t *dma_rx_buf;
+    uint8_t *dma_tx_buf;
+    uint32_t dma_buf_size_bytes;
+
+    /* Pre-allocated int16 conversion buffer for direct_write.
+     * Avoids heap_caps_malloc/free on every write call. */
+    int16_t *i2s_conv_buf;
+    uint32_t i2s_conv_buf_samples;
+
+    /* DMA completion semaphore - signaled by on_sent ISR callback */
+    SemaphoreHandle_t dma_sem;
+
+    /* Staging ring buffer: RTP RX writes int16 data here,
+     * DMA ISR reads from here to fill DMA descriptors directly. */
+    int16_t *staging_buf;
+    uint32_t staging_size;      /* Total size in int16 samples */
+    volatile uint32_t staging_wr;
+    volatile uint32_t staging_rd;
+    volatile uint32_t staging_underruns;  /* ISR couldn't fill full descriptor */
+    volatile uint32_t staging_isr_count;  /* Total ISR calls */
+    volatile bool staging_ready;          /* ISR reads only when true */
+
+    /* Frame callback */
+    aes67_audio_frame_cb_t frame_cb;
+    void *frame_cb_user_data;
+
+    /* I/O task */
+    TaskHandle_t io_task;
+    bool running;
+    bool use_psram;
+
+    /* Samples per frame period: sample_rate * packet_time_us / 1000000 */
+    uint32_t frame_size;
+};
+
+/* --- Helpers --------------------------------------------------------------- */
+
+/* Convert word_length (bytes per sample) to I2S bit width enum.
+ * Currently unused since we force 16-bit I2S for ES8311 compat,
+ * but kept for future use with other codecs. */
+__attribute__((unused))
+static i2s_data_bit_width_t word_length_to_bits(uint8_t word_length)
+{
+    switch (word_length) {
+    case 2:  return I2S_DATA_BIT_WIDTH_16BIT;
+    case 3:  return I2S_DATA_BIT_WIDTH_24BIT;
+    case 4:  return I2S_DATA_BIT_WIDTH_32BIT;
+    default: return I2S_DATA_BIT_WIDTH_24BIT;
+    }
+}
+
+/* In ESP32-P4 Philips I2S mode with 32-bit slot width, the DMA data
+ * is already left-justified (MSB-first) in the 32-bit word. This means
+ * the I2S DMA format matches our internal left-justified int32 format
+ * directly. No bit shifting is needed regardless of the configured
+ * data bit width (16/24/32), because the I2S hardware handles the
+ * slot-to-wire packing. */
+
+/* Available frames in a ring buffer (single-producer / single-consumer safe) */
+static inline uint32_t ring_available(uint32_t wr, uint32_t rd, uint32_t capacity)
+{
+    return (wr - rd) % capacity;
+}
+
+/* Free frames in a ring buffer */
+static inline uint32_t ring_free(uint32_t wr, uint32_t rd, uint32_t capacity)
+{
+    /* Reserve one frame to distinguish full from empty */
+    return capacity - 1 - ring_available(wr, rd, capacity);
+}
+
+/* --- DMA <-> Ring Buffer Conversion --------------------------------------- */
+
+/* Copy DMA buffer (32-bit slots) directly to capture ring buffer.
+ * Both DMA and ring buffer use left-justified int32 format. */
+__attribute__((unused))
+static void dma_to_capture_ring(struct aes67_audio_ctx *ctx,
+                                const uint8_t *dma_buf,
+                                uint32_t frame_count)
+{
+    const uint8_t ch = ctx->config.channels;
+    const uint32_t cap = ctx->buf_size_frames;
+    const int32_t *src = (const int32_t *)dma_buf;
+    uint32_t wr = ctx->capture_wr;
+
+    for (uint32_t f = 0; f < frame_count; f++) {
+        uint32_t ring_idx = (wr % cap) * ch;
+        for (uint8_t c = 0; c < ch; c++) {
+            ctx->capture_buf[ring_idx + c] = src[f * ch + c];
+        }
+        wr++;
+    }
+    ctx->capture_wr = wr;
+}
+
+/* Read from playback ring buffer and copy to DMA TX buffer.
+ * Both use left-justified int32 format - direct copy, no shifting. */
+__attribute__((unused))
+static void playback_ring_to_dma(struct aes67_audio_ctx *ctx,
+                                 uint8_t *dma_buf,
+                                 uint32_t frame_count)
+{
+    const uint8_t ch = ctx->config.channels;
+    const uint32_t cap = ctx->buf_size_frames;
+    int32_t *dst = (int32_t *)dma_buf;
+    uint32_t rd = ctx->playback_rd;
+
+    uint32_t avail = ring_available(ctx->playback_wr, rd, cap);
+
+    for (uint32_t f = 0; f < frame_count; f++) {
+        if (f < avail) {
+            uint32_t ring_idx = (rd % cap) * ch;
+            for (uint8_t c = 0; c < ch; c++) {
+                dst[f * ch + c] = ctx->playback_buf[ring_idx + c];
+            }
+            rd++;
+        } else {
+            for (uint8_t c = 0; c < ch; c++) {
+                dst[f * ch + c] = 0;
+            }
+        }
+    }
+    ctx->playback_rd = rd;
+}
+
+/* --- DMA callbacks -------------------------------------------------------- */
+
+/* --- StreamBuffer-based ISR playback --- */
+
+/* The Ethernet hook writes variable-size packets (52-192 frames depending
+ * on source ptime). The DMA ISR reads exactly 1536 bytes per fire
+ * (one descriptor). A byte-stream buffer naturally handles variable
+ * packet sizes by concatenating them until the ISR has a full descriptor. */
+static StreamBufferHandle_t s_isr_stream_buf = NULL;
+
+/* ISR diagnostics */
+static volatile uint32_t s_isr_fires = 0;
+static volatile uint32_t s_isr_full = 0;
+static volatile uint32_t s_isr_empty = 0;
+
+/* Write audio data to the stream buffer (called from Ethernet hook).
+ * Handles any packet size -- multiple small packets accumulate until
+ * the ISR reads a full DMA descriptor. */
+void aes67_audio_slot_write(const void *data, uint32_t len)
+{
+    if (s_isr_stream_buf && len > 0) {
+        xStreamBufferSend(s_isr_stream_buf, data, len, 0);
+    }
+}
+
+/* Last frame for sample-hold on underrun (less audible than silence) */
+static int32_t s_last_frame[2] = {0, 0};  /* Stereo max */
+
+/* ISR callback: reads from the stream buffer to fill one DMA descriptor.
+ * On underrun, uses sample-hold (repeat last frame) instead of silence
+ * to mask the ~8% EMAC packet loss with minimal artifacts. */
+static IRAM_ATTR bool on_i2s_tx_sent_sbuf(i2s_chan_handle_t handle,
+                                            i2s_event_data_t *event,
+                                            void *user_ctx)
+{
+    BaseType_t woken = pdFALSE;
+    s_isr_fires++;
+
+    if (s_isr_stream_buf) {
+        size_t received = xStreamBufferReceiveFromISR(s_isr_stream_buf,
+                                                       event->dma_buf,
+                                                       event->size,
+                                                       &woken);
+        if (received >= event->size) {
+            s_isr_full++;
+            /* Save last frame for sample-hold on next underrun */
+            int32_t *samples = (int32_t *)event->dma_buf;
+            uint32_t last_idx = (event->size / sizeof(int32_t)) - 2;
+            s_last_frame[0] = samples[last_idx];
+            s_last_frame[1] = samples[last_idx + 1];
+        } else {
+            s_isr_empty++;
+            /* Sample-hold: fill with last known frame instead of silence.
+             * Decays toward zero to avoid DC offset on stream stop. */
+            int32_t *dst = (int32_t *)event->dma_buf;
+            uint32_t n_samples = event->size / sizeof(int32_t);
+            /* Fill any partial data gap */
+            uint32_t start = received / sizeof(int32_t);
+            for (uint32_t i = start; i < n_samples; i += 2) {
+                dst[i]     = s_last_frame[0];
+                dst[i + 1] = s_last_frame[1];
+            }
+            /* Decay toward zero (fade over ~10 underruns = 10ms) */
+            s_last_frame[0] = (s_last_frame[0] * 7) / 8;
+            s_last_frame[1] = (s_last_frame[1] * 7) / 8;
+        }
+    } else {
+        s_isr_empty++;
+        memset(event->dma_buf, 0, event->size);
+    }
+
+    esp_cache_msync(event->dma_buf, event->size, ESP_CACHE_MSYNC_FLAG_DIR_C2M);
+    return woken == pdTRUE;
+}
+
+/* Log ISR stats -- only on first lock and on underruns */
+void aes67_audio_log_isr_stats(void)
+{
+    static bool first_100_logged = false;
+    static uint32_t prev_empty_total = 0;
+
+    uint32_t fires = s_isr_fires;
+    uint32_t full = s_isr_full;
+    uint32_t empty = s_isr_empty;
+    s_isr_fires = 0;
+    s_isr_full = 0;
+    s_isr_empty = 0;
+
+    if (fires == 0) return;
+
+    uint32_t pct = full * 100 / fires;
+
+    /* Log once when first reaching 100% */
+    if (!first_100_logged && pct == 100) {
+        first_100_logged = true;
+        ESP_LOGI("dma_isr", "locked: %lu Hz, 100%% utilization", (unsigned long)(fires / 2));
+    }
+
+    /* Log whenever there are underruns (after initial lock) */
+    if (first_100_logged && empty > 0) {
+        prev_empty_total += empty;
+        ESP_LOGW("dma_isr", "underrun: %lu empty in %lu fires (%lu%%), total=%lu",
+                 (unsigned long)empty, (unsigned long)fires,
+                 (unsigned long)pct, (unsigned long)prev_empty_total);
+    }
+}
+
+/* Initialize stream buffer for ISR playback */
+void aes67_audio_set_isr_stream_buf(aes67_audio_handle_t handle,
+                                     StreamBufferHandle_t sbuf)
+{
+    (void)handle;
+    s_isr_stream_buf = xStreamBufferCreate(65536, 1);
+    ESP_LOGI("aes67_audio", "ISR playback stream buffer: 64KB");
+}
+
+/* --- I2S RX Capture ISR (I2S -> AES67 TX source) --- */
+
+/* Capture stream buffer: RX DMA ISR writes here, TIC task reads */
+static StreamBufferHandle_t s_capture_stream_buf = NULL;
+
+/* RX DMA ISR: fires when a DMA descriptor finishes capturing.
+ * Copies captured int32 samples to the capture stream buffer.
+ * The TIC task reads from this buffer to feed RTP source streams. */
+static IRAM_ATTR bool on_i2s_rx_recv(i2s_chan_handle_t handle,
+                                      i2s_event_data_t *event,
+                                      void *user_ctx)
+{
+    if (s_capture_stream_buf && event->size > 0) {
+        /* Invalidate CPU cache: DMA wrote to memory, CPU cache may be stale */
+        esp_cache_msync(event->dma_buf, event->size, ESP_CACHE_MSYNC_FLAG_DIR_M2C);
+        BaseType_t woken = pdFALSE;
+        xStreamBufferSendFromISR(s_capture_stream_buf, event->dma_buf,
+                                  event->size, &woken);
+        return woken == pdTRUE;
+    }
+    return false;
+}
+
+/* Get the capture stream buffer (called by TIC task to read captured audio) */
+StreamBufferHandle_t aes67_audio_get_capture_buf(aes67_audio_handle_t handle)
+{
+    (void)handle;
+    return s_capture_stream_buf;
+}
+
+/* Initialize capture stream buffer for I2S RX -> RTP TX path */
+void aes67_audio_init_capture(aes67_audio_handle_t handle)
+{
+    (void)handle;
+    s_capture_stream_buf = xStreamBufferCreate(65536, 1);
+    ESP_LOGI("aes67_audio", "ISR capture stream buffer: 64KB");
+}
+
+/* Legacy staging ISR (unused, kept for reference) */
+__attribute__((unused))
+static IRAM_ATTR bool on_i2s_tx_sent(i2s_chan_handle_t handle,
+                                      i2s_event_data_t *event,
+                                      void *user_ctx)
+{
+    struct aes67_audio_ctx *ctx = (struct aes67_audio_ctx *)user_ctx;
+    int16_t *dma_buf = (int16_t *)event->dma_buf;
+    uint32_t dma_samples = event->size / sizeof(int16_t);
+
+    /* Don't read from staging until it has enough data */
+    if (!ctx->staging_ready) {
+        memset(dma_buf, 0, event->size);
+        esp_cache_msync(event->dma_buf, event->size, ESP_CACHE_MSYNC_FLAG_DIR_C2M);
+        ctx->staging_isr_count++;
+        return false;
+    }
+
+    /* Copy from staging ring to DMA buffer */
+    uint32_t rd = ctx->staging_rd;
+    uint32_t wr = ctx->staging_wr;
+    uint32_t cap = ctx->staging_size;
+    uint32_t avail = (wr >= rd) ? (wr - rd) : (cap - rd + wr);
+
+    uint32_t to_copy = (avail < dma_samples) ? avail : dma_samples;
+    uint32_t first = cap - (rd % cap);
+    if (first > to_copy) first = to_copy;
+
+    /* Fast copy from staging ring (internal SRAM, no cache issues) */
+    memcpy(dma_buf, &ctx->staging_buf[rd % cap], first * sizeof(int16_t));
+    if (to_copy > first) {
+        memcpy(dma_buf + first, ctx->staging_buf,
+               (to_copy - first) * sizeof(int16_t));
+    }
+
+    /* Zero-fill if not enough data (silence on underrun) */
+    if (to_copy < dma_samples) {
+        memset(dma_buf + to_copy, 0, (dma_samples - to_copy) * sizeof(int16_t));
+    }
+
+    ctx->staging_rd = (rd + to_copy) % cap;
+    ctx->staging_isr_count++;
+    if (to_copy < dma_samples) {
+        ctx->staging_underruns++;
+    }
+
+    /* Flush CPU cache to memory so DMA controller sees the new data.
+     * ESP32-P4 routes internal SRAM through L1 cache -- without this
+     * sync, DMA reads stale data and produces crackling. */
+    esp_cache_msync(event->dma_buf, event->size, ESP_CACHE_MSYNC_FLAG_DIR_C2M);
+
+    /* Notify playback task to refill staging buffer */
+    BaseType_t woken = pdFALSE;
+    xSemaphoreGiveFromISR(ctx->dma_sem, &woken);
+    return woken == pdTRUE;
+}
+
+/* --- Public API ----------------------------------------------------------- */
+
+esp_err_t aes67_audio_init(const aes67_audio_config_t *audio_config,
+                           const aes67_i2s_pins_t *pins,
+                           bool use_psram,
+                           uint16_t ring_buf_ms,
+                           aes67_audio_handle_t *handle)
+{
+    if (!audio_config || !pins || !handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_audio_ctx *ctx = calloc(1, sizeof(*ctx));
+    if (!ctx) {
+        ESP_LOGE(TAG, "Failed to allocate audio context");
+        return ESP_ERR_NO_MEM;
+    }
+
+    memcpy(&ctx->config, audio_config, sizeof(aes67_audio_config_t));
+    ctx->use_psram = use_psram;
+
+    /* Compute frame size: samples per packet period */
+    ctx->frame_size = (uint32_t)audio_config->sample_rate *
+                      audio_config->packet_time_us / 1000000;
+    if (ctx->frame_size == 0) {
+        ESP_LOGE(TAG, "Invalid sample_rate/packet_time combination");
+        free(ctx);
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    ESP_LOGI(TAG, "Init: %lu Hz, %u ch, %u bytes/sample, frame_size=%lu",
+             (unsigned long)audio_config->sample_rate,
+             audio_config->channels,
+             audio_config->word_length,
+             (unsigned long)ctx->frame_size);
+
+    /* --- Allocate ring buffers --- */
+    uint32_t ring_frames = (audio_config->sample_rate * ring_buf_ms) / 1000;
+    if (ring_frames < ctx->frame_size * 4) {
+        ring_frames = ctx->frame_size * 4;
+    }
+    /* Add 1 for the sentinel frame (full/empty disambiguation) */
+    ring_frames += 1;
+    ctx->buf_size_frames = ring_frames;
+
+    size_t ring_bytes = ring_frames * audio_config->channels * sizeof(int32_t);
+    uint32_t alloc_caps = use_psram ? MALLOC_CAP_SPIRAM : MALLOC_CAP_DEFAULT;
+
+    ctx->capture_buf = heap_caps_calloc(1, ring_bytes, alloc_caps);
+    ctx->playback_buf = heap_caps_calloc(1, ring_bytes, alloc_caps);
+    if (!ctx->capture_buf || !ctx->playback_buf) {
+        ESP_LOGE(TAG, "Failed to allocate ring buffers (%u bytes each)", (unsigned)ring_bytes);
+        goto err_free_bufs;
+    }
+
+    /* --- Allocate DMA staging buffers (internal SRAM) --- */
+    ctx->dma_buf_size_bytes = ctx->frame_size * audio_config->channels * sizeof(int32_t);
+    ctx->dma_rx_buf = heap_caps_calloc(1, ctx->dma_buf_size_bytes,
+                                       MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
+    ctx->dma_tx_buf = heap_caps_calloc(1, ctx->dma_buf_size_bytes,
+                                       MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
+    if (!ctx->dma_rx_buf || !ctx->dma_tx_buf) {
+        ESP_LOGE(TAG, "Failed to allocate DMA buffers (%lu bytes each)",
+                 (unsigned long)ctx->dma_buf_size_bytes);
+        goto err_free_bufs;
+    }
+
+    /* --- Configure I2S channels --- */
+    /* Official ESP-IDF es8311 example for ESP32-P4 uses I2S_NUM_0 */
+    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
+    chan_cfg.dma_desc_num = DMA_DESC_NUM;
+    chan_cfg.dma_frame_num = DMA_FRAME_NUM;
+    /* When using ISR callback, auto_clear doesn't matter because the ISR
+     * fills every descriptor (with data or silence). Set false for
+     * i2s_channel_write compatibility in non-ISR modes. */
+    chan_cfg.auto_clear = (s_isr_stream_buf != NULL);
+
+    esp_err_t ret = i2s_new_channel(&chan_cfg, &ctx->tx_chan, &ctx->rx_chan);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "i2s_new_channel failed: %s", esp_err_to_name(ret));
+        goto err_free_bufs;
+    }
+
+    /* Map word_length to I2S bit depth */
+    i2s_slot_mode_t slot_mode = I2S_SLOT_MODE_STEREO;
+
+    /* 32-bit I2S: int32 maps directly to DMA, no conversion needed.
+     * ES8311 supports 32-bit (SDP_IN_WL = 100). */
+    i2s_std_config_t std_cfg = {
+        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(audio_config->sample_rate),
+        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, slot_mode),
+    };
+
+    /* Use APLL clock source for precise audio frequency control.
+     * APLL has SDM (Sigma-Delta Modulator) for sub-ppm tuning.
+     * ESP32-P4 Ethernet uses MPLL, so no APLL conflict. */
+    std_cfg.clk_cfg.clk_src = I2S_CLK_SRC_APLL;
+    std_cfg.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_384;
+
+    std_cfg.gpio_cfg = (i2s_std_gpio_config_t){
+        .mclk = pins->mck_gpio,
+        .bclk = pins->bck_gpio,
+        .ws   = pins->ws_gpio,
+        .dout = pins->dout_gpio,
+        .din  = pins->din_gpio,
+        .invert_flags = {
+            .mclk_inv = false,
+            .bclk_inv = false,
+            .ws_inv   = false,
+        },
+    };
+
+    ret = i2s_channel_init_std_mode(ctx->tx_chan, &std_cfg);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "i2s_channel_init_std_mode TX failed: %s", esp_err_to_name(ret));
+        goto err_del_channel;
+    }
+
+    ret = i2s_channel_init_std_mode(ctx->rx_chan, &std_cfg);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "i2s_channel_init_std_mode RX failed: %s", esp_err_to_name(ret));
+        goto err_del_channel;
+    }
+
+    ctx->running = false;
+    ctx->capture_wr = 0;
+    ctx->capture_rd = 0;
+    ctx->playback_wr = 0;
+    ctx->playback_rd = 0;
+
+    /* Staging ring: RTP RX writes int16, DMA ISR reads.
+     * 10 packets of headroom to absorb network jitter and
+     * phase differences between packet arrival and DMA consumption. */
+    ctx->staging_size = ctx->frame_size * 10 * audio_config->channels;
+    ctx->staging_buf = heap_caps_calloc(ctx->staging_size, sizeof(int16_t),
+                                         MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+    if (!ctx->staging_buf) {
+        ESP_LOGE(TAG, "Failed to allocate staging ring");
+        goto err_del_channel;
+    }
+    ctx->staging_wr = 0;
+    ctx->staging_rd = 0;
+    ctx->staging_ready = false;
+
+    /* Binary semaphore: DMA ISR signals when descriptor consumed */
+    ctx->dma_sem = xSemaphoreCreateBinary();
+    if (!ctx->dma_sem) {
+        ESP_LOGE(TAG, "Failed to create DMA semaphore");
+        goto err_del_channel;
+    }
+
+    /* Pre-allocate int16 conversion buffer for direct_write.
+     * Size for max batch: 25 packets worth of frames. */
+    ctx->i2s_conv_buf_samples = ctx->frame_size * 25 * audio_config->channels;
+    ctx->i2s_conv_buf = heap_caps_malloc(
+        ctx->i2s_conv_buf_samples * sizeof(int16_t),
+        MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+    if (!ctx->i2s_conv_buf) {
+        ESP_LOGE(TAG, "Failed to allocate I2S conversion buffer (%lu bytes)",
+                 (unsigned long)(ctx->i2s_conv_buf_samples * sizeof(int16_t)));
+        goto err_del_channel;
+    }
+
+    *handle = ctx;
+    ESP_LOGI(TAG, "Audio driver initialized (ring=%lu frames, dma=%lu bytes)",
+             (unsigned long)ring_frames, (unsigned long)ctx->dma_buf_size_bytes);
+    return ESP_OK;
+
+err_del_channel:
+    i2s_del_channel(ctx->tx_chan);
+    i2s_del_channel(ctx->rx_chan);
+err_free_bufs:
+    heap_caps_free(ctx->capture_buf);
+    heap_caps_free(ctx->playback_buf);
+    heap_caps_free(ctx->dma_rx_buf);
+    heap_caps_free(ctx->dma_tx_buf);
+    free(ctx);
+    return ESP_ERR_NO_MEM;
+}
+
+esp_err_t aes67_audio_start(aes67_audio_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (handle->running) {
+        ESP_LOGW(TAG, "Audio already running");
+        return ESP_OK;
+    }
+
+    /* Register DMA ISR callback for direct stream buffer -> DMA transfer.
+     * This bypasses i2s_channel_write and the playback task entirely. */
+    /* Register TX DMA ISR (playback: stream buffer -> I2S) */
+    if ((s_isr_stream_buf != NULL)) {
+        i2s_event_callbacks_t tx_cbs = {
+            .on_recv = NULL,
+            .on_recv_q_ovf = NULL,
+            .on_sent = on_i2s_tx_sent_sbuf,
+            .on_send_q_ovf = NULL,
+        };
+        esp_err_t cb_ret = i2s_channel_register_event_callback(
+            handle->tx_chan, &tx_cbs, handle);
+        if (cb_ret != ESP_OK) {
+            ESP_LOGW(TAG, "Failed to register TX ISR: %s", esp_err_to_name(cb_ret));
+        } else {
+            ESP_LOGI(TAG, "TX DMA ISR registered (playback)");
+        }
+    }
+
+    /* Register RX DMA ISR (capture: I2S -> stream buffer) */
+    if (s_capture_stream_buf != NULL) {
+        i2s_event_callbacks_t rx_cbs = {
+            .on_recv = on_i2s_rx_recv,
+            .on_recv_q_ovf = NULL,
+            .on_sent = NULL,
+            .on_send_q_ovf = NULL,
+        };
+        esp_err_t cb_ret = i2s_channel_register_event_callback(
+            handle->rx_chan, &rx_cbs, handle);
+        if (cb_ret != ESP_OK) {
+            ESP_LOGW(TAG, "Failed to register RX ISR: %s", esp_err_to_name(cb_ret));
+        } else {
+            ESP_LOGI(TAG, "RX DMA ISR registered (capture)");
+        }
+    }
+
+    /* Enable both TX and RX channels. The ES8311 codec requires both
+     * to be active (matching the official ESP-IDF es8311 example). */
+    esp_err_t ret = i2s_channel_enable(handle->tx_chan);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "Failed to enable TX channel: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    ret = i2s_channel_enable(handle->rx_chan);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "Failed to enable RX channel: %s", esp_err_to_name(ret));
+        /* Non-fatal: TX still works */
+    }
+
+    handle->running = true;
+
+    /* Read and cache the clock divider set by i2s_channel_init_std_mode.
+     * This gives us the correct base values for adaptive clock recovery. */
+    extern void aes67_audio_read_clock_divider(aes67_audio_handle_t h);
+    aes67_audio_read_clock_divider(handle);
+
+    ESP_LOGI(TAG, "Audio started (DMA: %d desc x %d frames, DMA-paced)",
+             DMA_DESC_NUM, DMA_FRAME_NUM);
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_stop(aes67_audio_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (!handle->running) {
+        return ESP_OK;
+    }
+
+    handle->running = false;
+
+    /* Disable I2S channels */
+    i2s_channel_disable(handle->tx_chan);
+    i2s_channel_disable(handle->rx_chan);
+
+    ESP_LOGI(TAG, "Audio stopped");
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_destroy(aes67_audio_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    /* Stop if still running */
+    if (handle->running) {
+        aes67_audio_stop(handle);
+    }
+
+    /* Delete I2S channels */
+    i2s_del_channel(handle->tx_chan);
+    i2s_del_channel(handle->rx_chan);
+
+    /* Free all buffers */
+    heap_caps_free(handle->capture_buf);
+    heap_caps_free(handle->playback_buf);
+    heap_caps_free(handle->dma_rx_buf);
+    heap_caps_free(handle->dma_tx_buf);
+    heap_caps_free(handle->i2s_conv_buf);
+
+    free(handle);
+    ESP_LOGI(TAG, "Audio driver destroyed");
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_register_frame_cb(aes67_audio_handle_t handle,
+                                        aes67_audio_frame_cb_t cb,
+                                        void *user_data)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    handle->frame_cb = cb;
+    handle->frame_cb_user_data = user_data;
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_write_playback(aes67_audio_handle_t handle,
+                                     const void *samples, uint32_t frame_count)
+{
+    if (!handle || !samples) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    const int32_t *src = (const int32_t *)samples;
+    const uint8_t ch = handle->config.channels;
+    const uint32_t cap = handle->buf_size_frames;
+
+    uint32_t free_frames = ring_free(handle->playback_wr, handle->playback_rd, cap);
+    if (frame_count > free_frames) {
+        /* Not enough room. Just overwrite - the I2S read task will
+         * pick up the latest data. Don't touch playback_rd here as
+         * that would race with the I2S I/O task reading it. */
+        frame_count = free_frames;
+        if (frame_count == 0) return ESP_OK;
+    }
+
+    uint32_t wr = handle->playback_wr;
+    for (uint32_t f = 0; f < frame_count; f++) {
+        uint32_t ring_idx = (wr % cap) * ch;
+        for (uint8_t c = 0; c < ch; c++) {
+            handle->playback_buf[ring_idx + c] = src[f * ch + c];
+        }
+        wr++;
+    }
+    handle->playback_wr = wr;
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_read_capture(aes67_audio_handle_t handle,
+                                   void *samples, uint32_t frame_count)
+{
+    if (!handle || !samples) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    int32_t *dst = (int32_t *)samples;
+    const uint8_t ch = handle->config.channels;
+    const uint32_t cap = handle->buf_size_frames;
+
+    uint32_t avail = ring_available(handle->capture_wr, handle->capture_rd, cap);
+    if (frame_count > avail) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    uint32_t rd = handle->capture_rd;
+    for (uint32_t f = 0; f < frame_count; f++) {
+        uint32_t ring_idx = (rd % cap) * ch;
+        for (uint8_t c = 0; c < ch; c++) {
+            dst[f * ch + c] = handle->capture_buf[ring_idx + c];
+        }
+        rd++;
+    }
+    handle->capture_rd = rd;
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_direct_write(aes67_audio_handle_t handle,
+                                   const int32_t *samples, uint32_t frame_count)
+{
+    if (!handle || !samples || frame_count == 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    uint32_t total_samples = frame_count * handle->config.channels;
+
+    /* Clamp to pre-allocated buffer capacity */
+    if (total_samples > handle->i2s_conv_buf_samples) {
+        total_samples = handle->i2s_conv_buf_samples;
+        frame_count = total_samples / handle->config.channels;
+    }
+
+    /* With 24-bit I2S (32-bit slots), write int32 directly to DMA.
+     * The L24 conversion already produces left-justified int32.
+     * No truncation needed -- full 24-bit quality preserved. */
+    size_t bytes_written = 0;
+    size_t bytes_to_write = total_samples * sizeof(int32_t);
+
+    int64_t t0 = esp_timer_get_time();
+    esp_err_t ret = i2s_channel_write(handle->tx_chan, samples, bytes_to_write,
+                                       &bytes_written, portMAX_DELAY);
+    int64_t t1 = esp_timer_get_time();
+
+    /* Measure i2s_channel_write timing to find the throughput bottleneck */
+    static int64_t total_write_us = 0;
+    static int64_t total_between_us = 0;
+    static int64_t last_end_us = 0;
+    if (last_end_us > 0) {
+        total_between_us += (t0 - last_end_us);
+    }
+    last_end_us = t1;
+    total_write_us += (t1 - t0);
+
+    /* Log first few writes with sample values for debugging format issues */
+    static uint32_t write_log_count = 0;
+    if (write_log_count < 3) {
+        write_log_count++;
+        ESP_LOGI(TAG, "I2S write #%lu: %u/%u bytes, frames=%lu, ret=%s, "
+                 "write=%lldus, between=%lldus | "
+                 "int32[0..3]: %+ld %+ld %+ld %+ld -> "
+                 "int16[0..3]: %+d %+d %+d %+d",
+                 (unsigned long)write_log_count,
+                 (unsigned)bytes_written, (unsigned)bytes_to_write,
+                 (unsigned long)frame_count, esp_err_to_name(ret),
+                 (long long)(t1 - t0),
+                 (long long)(last_end_us > 0 ? (t0 - (last_end_us - (t1 - t0))) : 0),
+                 (long)samples[0], (long)samples[1],
+                 (long)samples[2], (long)samples[3],
+                 (long)samples[0], (long)samples[1],
+                 (long)samples[2], (long)samples[3]);
+    }
+
+    return ret;
+}
+
+esp_err_t aes67_audio_get_config(aes67_audio_handle_t handle,
+                                 aes67_audio_config_t *config)
+{
+    if (!handle || !config) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    memcpy(config, &handle->config, sizeof(aes67_audio_config_t));
+    return ESP_OK;
+}
+
+esp_err_t aes67_audio_get_buffer_levels(aes67_audio_handle_t handle,
+                                        uint32_t *capture_frames,
+                                        uint32_t *playback_frames)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    if (capture_frames) {
+        *capture_frames = ring_available(handle->capture_wr, handle->capture_rd,
+                                         handle->buf_size_frames);
+    }
+    if (playback_frames) {
+        *playback_frames = ring_available(handle->playback_wr, handle->playback_rd,
+                                          handle->buf_size_frames);
+    }
+
+    return ESP_OK;
+}
+
+/* Write int16 stereo samples to the staging ring buffer.
+ * Called by the playback task. The DMA ISR reads from this ring. */
+esp_err_t aes67_audio_staging_write(aes67_audio_handle_t handle,
+                                     const int16_t *samples, uint32_t sample_count)
+{
+    if (!handle || !samples) return ESP_ERR_INVALID_ARG;
+
+    uint32_t cap = handle->staging_size;
+    uint32_t wr = handle->staging_wr;
+    uint32_t rd = handle->staging_rd;
+    uint32_t avail = (wr >= rd) ? (wr - rd) : (cap - rd + wr);
+    uint32_t free_samples = cap - 1 - avail;  /* -1 for sentinel */
+
+    if (sample_count > free_samples) {
+        sample_count = free_samples;
+    }
+    if (sample_count == 0) return ESP_OK;
+
+    uint32_t pos = wr % cap;
+    uint32_t first = cap - pos;
+    if (first > sample_count) first = sample_count;
+
+    memcpy(&handle->staging_buf[pos], samples, first * sizeof(int16_t));
+    if (sample_count > first) {
+        memcpy(handle->staging_buf, samples + first,
+               (sample_count - first) * sizeof(int16_t));
+    }
+
+    handle->staging_wr = (wr + sample_count) % cap;
+
+    /* Enable ISR reads once staging is at least half full (20ms).
+     * More headroom = more tolerance for network jitter. */
+    if (!handle->staging_ready) {
+        uint32_t new_wr = handle->staging_wr;
+        uint32_t cur_rd = handle->staging_rd;
+        uint32_t filled = (new_wr >= cur_rd) ? (new_wr - cur_rd) : (cap - cur_rd + new_wr);
+        if (filled >= cap / 2) {
+            handle->staging_ready = true;
+        }
+    }
+
+    return ESP_OK;
+}
+
+void aes67_audio_get_staging_stats(aes67_audio_handle_t handle,
+                                    uint32_t *isr_count, uint32_t *underruns)
+{
+    if (handle) {
+        if (isr_count) *isr_count = handle->staging_isr_count;
+        if (underruns) *underruns = handle->staging_underruns;
+    }
+}
+
+/* Cached base divider values (read from registers after I2S init) */
+static uint32_t base_div_n = 0;
+static uint32_t base_div_x = 0;
+static uint32_t base_div_y = 0;
+static uint32_t base_div_z = 0;
+static uint32_t base_div_yn1 = 0;
+static uint32_t base_numerator = 0;
+static uint32_t base_denominator = 0;
+
+/* Read and cache the I2S0 TX clock divider from hardware registers.
+ * Must be called AFTER i2s_channel_init_std_mode(). */
+void aes67_audio_read_clock_divider(aes67_audio_handle_t handle)
+{
+    (void)handle;
+    base_div_n = HP_SYS_CLKRST.peri_clk_ctrl13.reg_i2s0_tx_div_n;
+    base_div_x = HP_SYS_CLKRST.peri_clk_ctrl13.reg_i2s0_tx_div_x;
+    base_div_y = HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_y;
+    base_div_z = HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_z;
+    base_div_yn1 = HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_yn1;
+
+    /* Reverse the x,y,z,yn1 -> numerator,denominator conversion */
+    if (base_div_z > 0) {
+        base_denominator = (base_div_x + 1) * base_div_z + base_div_y;
+        base_numerator = base_div_yn1 ? (base_denominator - base_div_z) : base_div_z;
+    }
+
+    ESP_LOGI("aes67_audio", "I2S0 TX clk: N=%lu, x=%lu, y=%lu, z=%lu, yn1=%lu "
+             "-> numer=%lu denom=%lu (divider=N+%lu/%lu)",
+             (unsigned long)base_div_n, (unsigned long)base_div_x,
+             (unsigned long)base_div_y, (unsigned long)base_div_z,
+             (unsigned long)base_div_yn1,
+             (unsigned long)base_numerator, (unsigned long)base_denominator,
+             (unsigned long)base_numerator, (unsigned long)base_denominator);
+}
+
+/* Adjust the I2S TX MCLK divider at runtime to fine-tune sample rate.
+ * This is used for adaptive clock recovery: speed up or slow down the
+ * I2S clock to match the network source rate.
+ * ppm_adj: parts-per-million adjustment. +100 = 0.01% faster. */
+void aes67_audio_adjust_clock_ppm(aes67_audio_handle_t handle, int32_t ppm_adj)
+{
+    if (!handle || base_denominator == 0) return;
+
+    /* Adjust numerator relative to the cached base values.
+     * Increasing numerator = larger divider = slower clock.
+     * ppm_adj > 0 means "source is faster, speed up I2S" = decrease numerator. */
+    int64_t adj = (int64_t)ppm_adj * (int64_t)base_denominator / 1000000;
+    int32_t new_numer = (int32_t)base_numerator - (int32_t)adj;
+    if (new_numer < 1) new_numer = 1;
+    if (new_numer > (int32_t)base_denominator - 1) new_numer = base_denominator - 1;
+
+    /* Convert numerator/denominator back to x,y,z,yn1 format */
+    uint32_t numer = (uint32_t)new_numer;
+    uint32_t denom = base_denominator;
+    uint32_t div_yn1 = numer * 2 > denom;
+    uint32_t div_z = div_yn1 ? denom - numer : numer;
+    uint32_t div_x = div_z ? (denom / div_z - 1) : 0;
+    uint32_t div_y = div_z ? (denom % div_z) : 0;
+
+    /* Write to I2S0 TX clock divider registers.
+     * Workaround: set small div first, then target. */
+    HAL_FORCE_MODIFY_U32_REG_FIELD(HP_SYS_CLKRST.peri_clk_ctrl13,
+                                    reg_i2s0_tx_div_n, 2);
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_yn1 = 0;
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_y = 1;
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_z = 0;
+    HP_SYS_CLKRST.peri_clk_ctrl13.reg_i2s0_tx_div_x = 0;
+    /* Set target */
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_yn1 = div_yn1;
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_z = div_z;
+    HP_SYS_CLKRST.peri_clk_ctrl14.reg_i2s0_tx_div_y = div_y;
+    HP_SYS_CLKRST.peri_clk_ctrl13.reg_i2s0_tx_div_x = div_x;
+    HAL_FORCE_MODIFY_U32_REG_FIELD(HP_SYS_CLKRST.peri_clk_ctrl13,
+                                    reg_i2s0_tx_div_n, base_div_n);
+}
+
+SemaphoreHandle_t aes67_audio_get_dma_sem(aes67_audio_handle_t handle)
+{
+    return handle ? handle->dma_sem : NULL;
+}
+
+i2s_chan_handle_t aes67_audio_get_tx_chan(aes67_audio_handle_t handle)
+{
+    return handle ? handle->tx_chan : NULL;
+}

+ 175 - 0
components/aes67/src/aes67_convert.c

@@ -0,0 +1,175 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Audio sample format conversion routines.
+ *
+ * Based on RAVENNA MTConvert. All conversions operate on interleaved
+ * audio data, converting between native little-endian 32-bit aligned
+ * samples and big-endian packed network (RTP payload) format.
+ *
+ * Hot-path functions are placed in IRAM to avoid flash cache misses
+ * during real-time audio processing.
+ */
+
+#include "aes67_convert.h"
+#include <string.h>
+#include "esp_attr.h"
+
+/* Byte swap helpers - ESP32-P4 is little-endian */
+static inline uint16_t bswap16(uint16_t v)
+{
+    return __builtin_bswap16(v);
+}
+
+static inline uint32_t bswap32(uint32_t v)
+{
+    return __builtin_bswap32(v);
+}
+
+/* --- L16: 16-bit linear PCM --- */
+
+IRAM_ATTR void aes67_convert_to_l16(const int32_t *src, uint8_t *dst,
+                                     uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    uint16_t *out = (uint16_t *)dst;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        int16_t val = (int16_t)(src[i] >> 16);
+        out[i] = bswap16((uint16_t)val);
+    }
+}
+
+IRAM_ATTR void aes67_convert_from_l16(const uint8_t *src, int32_t *dst,
+                                       uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    const uint16_t *in = (const uint16_t *)src;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        int16_t val = (int16_t)bswap16(in[i]);
+        dst[i] = ((int32_t)val) << 16;
+    }
+}
+
+/* --- L24: 24-bit linear PCM --- */
+
+IRAM_ATTR void aes67_convert_to_l24(const int32_t *src, uint8_t *dst,
+                                     uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        int32_t val = src[i];
+        dst[i * 3 + 0] = (uint8_t)(val >> 24);
+        dst[i * 3 + 1] = (uint8_t)(val >> 16);
+        dst[i * 3 + 2] = (uint8_t)(val >> 8);
+    }
+}
+
+IRAM_ATTR void aes67_convert_from_l24(const uint8_t *src, int32_t *dst,
+                                       uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        int32_t val = ((int32_t)(int8_t)src[i * 3 + 0]) << 24;
+        val |= ((uint32_t)src[i * 3 + 1]) << 16;
+        val |= ((uint32_t)src[i * 3 + 2]) << 8;
+        dst[i] = val;
+    }
+}
+
+/* --- L32: 32-bit linear PCM --- */
+
+IRAM_ATTR void aes67_convert_to_l32(const int32_t *src, uint8_t *dst,
+                                     uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    uint32_t *out = (uint32_t *)dst;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        out[i] = bswap32((uint32_t)src[i]);
+    }
+}
+
+IRAM_ATTR void aes67_convert_from_l32(const uint8_t *src, int32_t *dst,
+                                       uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    const uint32_t *in = (const uint32_t *)src;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        dst[i] = (int32_t)bswap32(in[i]);
+    }
+}
+
+/* --- AM824: IEC 61937 in 4-byte words --- */
+
+/* For basic PCM passthrough, AM824 is byte-swapped int32 like L32.
+ * The lower 8 bits carry channel status/validity but we zero them
+ * on TX and ignore them on RX for PCM content. */
+IRAM_ATTR void aes67_convert_to_am824(const int32_t *src, uint8_t *dst,
+                                       uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    uint32_t *out = (uint32_t *)dst;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        /* Pack audio into upper 24 bits, clear validity/channel status */
+        out[i] = bswap32((uint32_t)(src[i] & 0xFFFFFF00));
+    }
+}
+
+IRAM_ATTR void aes67_convert_from_am824(const uint8_t *src, int32_t *dst,
+                                         uint32_t frame_count, uint8_t channels)
+{
+    uint32_t total_samples = frame_count * channels;
+    const uint32_t *in = (const uint32_t *)src;
+
+    for (uint32_t i = 0; i < total_samples; i++) {
+        /* Extract audio from upper 24 bits, mask off channel status */
+        dst[i] = (int32_t)(bswap32(in[i]) & 0xFFFFFF00);
+    }
+}
+
+/* --- Function pointer resolvers (MTConvert pattern) --- */
+
+aes67_convert_to_net_fn aes67_get_to_net_fn(uint8_t word_length)
+{
+    switch (word_length) {
+    case 2: return aes67_convert_to_l16;
+    case 3: return aes67_convert_to_l24;
+    case 4: return aes67_convert_to_l32;
+    default: return aes67_convert_to_l24;
+    }
+}
+
+aes67_convert_from_net_fn aes67_get_from_net_fn(uint8_t word_length)
+{
+    switch (word_length) {
+    case 2: return aes67_convert_from_l16;
+    case 3: return aes67_convert_from_l24;
+    case 4: return aes67_convert_from_l32;
+    default: return aes67_convert_from_l24;
+    }
+}
+
+/* --- Generic dispatch (for non-hot-path use) --- */
+
+void aes67_convert_to_net(const int32_t *src, uint8_t *dst,
+                          uint32_t frame_count, uint8_t channels,
+                          uint8_t word_length)
+{
+    aes67_get_to_net_fn(word_length)(src, dst, frame_count, channels);
+}
+
+void aes67_convert_from_net(const uint8_t *src, int32_t *dst,
+                            uint32_t frame_count, uint8_t channels,
+                            uint8_t word_length)
+{
+    aes67_get_from_net_fn(word_length)(src, dst, frame_count, channels);
+}

+ 144 - 0
components/aes67/src/aes67_hw_timer.c

@@ -0,0 +1,144 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Hardware PTP-synchronized audio frame timer.
+ *
+ * Uses the ESP32-P4 EMAC's target time interrupt to fire a callback
+ * at precise PTP-clock-aligned intervals. The target time mechanism
+ * programs an absolute PTP timestamp; when the EMAC PTP counter
+ * reaches that value, a hardware ISR fires. We use this to create
+ * a periodic frame timer synchronized to the PTP clock.
+ *
+ * This eliminates software timing jitter from vTaskDelay or software
+ * timers, giving nanosecond-precision audio frame boundaries that
+ * track the PTP master clock.
+ */
+
+#include "aes67_hw_timer.h"
+#include "esp_eth_time.h"
+#include "esp_log.h"
+#include "esp_attr.h"
+
+#include <string.h>
+#include <time.h>
+#include <sys/time.h>
+
+static const char *TAG = "aes67_hw_timer";
+
+static struct {
+    uint32_t frame_period_ns;
+    aes67_hw_timer_cb_t cb;
+    void *user_data;
+    struct timespec next_time;
+    bool running;
+} s_timer;
+
+/* Add nanoseconds to a timespec, handling overflow */
+static inline void timespec_add_ns(struct timespec *ts, uint32_t ns)
+{
+    ts->tv_nsec += ns;
+    while (ts->tv_nsec >= 1000000000L) {
+        ts->tv_nsec -= 1000000000L;
+        ts->tv_sec++;
+    }
+}
+
+/* ISR callback from EMAC when PTP clock reaches target time.
+ * Runs in ISR context - must be fast and in IRAM. */
+IRAM_ATTR static bool hw_timer_isr(esp_eth_mediator_t *eth, void *user_args)
+{
+    if (!s_timer.running) {
+        return false;
+    }
+
+    /* Fire the user callback */
+    if (s_timer.cb) {
+        s_timer.cb(s_timer.user_data);
+    }
+
+    /* Schedule the next target time */
+    timespec_add_ns(&s_timer.next_time, s_timer.frame_period_ns);
+
+    /* Verify the next time is still in the future. If we're behind
+     * (e.g., after a PTP clock jump), re-anchor to now + one period
+     * instead of looping to catch up (which would hang the ISR). */
+    struct timespec now;
+    esp_eth_clock_gettime(CLOCK_PTP_SYSTEM, &now);
+
+    int64_t diff_ns = ((int64_t)s_timer.next_time.tv_sec - now.tv_sec) * 1000000000LL
+                    + (s_timer.next_time.tv_nsec - now.tv_nsec);
+
+    if (diff_ns <= 0) {
+        /* We're behind - re-anchor to next aligned boundary from now */
+        uint64_t now_ns = (uint64_t)now.tv_sec * 1000000000ULL + now.tv_nsec;
+        uint64_t period = s_timer.frame_period_ns;
+        uint64_t next_ns = ((now_ns / period) + 1) * period;
+        s_timer.next_time.tv_sec = (time_t)(next_ns / 1000000000ULL);
+        s_timer.next_time.tv_nsec = (long)(next_ns % 1000000000ULL);
+    }
+
+    esp_eth_clock_set_target_time(CLOCK_PTP_SYSTEM, &s_timer.next_time);
+
+    return false;
+}
+
+esp_err_t aes67_hw_timer_init(uint32_t frame_period_ns,
+                               aes67_hw_timer_cb_t cb, void *user_data)
+{
+    if (!cb || frame_period_ns == 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    memset(&s_timer, 0, sizeof(s_timer));
+    s_timer.frame_period_ns = frame_period_ns;
+    s_timer.cb = cb;
+    s_timer.user_data = user_data;
+    s_timer.running = false;
+
+    /* Register the ISR callback with the EMAC PTP target time mechanism */
+    int ret = esp_eth_clock_register_target_cb(CLOCK_PTP_SYSTEM, hw_timer_isr);
+    if (ret != 0) {
+        ESP_LOGE(TAG, "failed to register target time callback: %d", ret);
+        return ESP_FAIL;
+    }
+
+    ESP_LOGI(TAG, "initialized (period=%lu ns)", (unsigned long)frame_period_ns);
+    return ESP_OK;
+}
+
+esp_err_t aes67_hw_timer_start(void)
+{
+    /* Get current PTP time and align to the next frame boundary */
+    struct timespec now;
+    if (esp_eth_clock_gettime(CLOCK_PTP_SYSTEM, &now) != 0) {
+        ESP_LOGE(TAG, "failed to get PTP time");
+        return ESP_FAIL;
+    }
+
+    /* Align to next frame boundary: round up to nearest frame_period_ns */
+    uint64_t now_ns = (uint64_t)now.tv_sec * 1000000000ULL + now.tv_nsec;
+    uint64_t period = s_timer.frame_period_ns;
+    uint64_t next_ns = ((now_ns / period) + 1) * period;
+
+    s_timer.next_time.tv_sec = (time_t)(next_ns / 1000000000ULL);
+    s_timer.next_time.tv_nsec = (long)(next_ns % 1000000000ULL);
+    s_timer.running = true;
+
+    /* Program the first target time */
+    esp_eth_clock_set_target_time(CLOCK_PTP_SYSTEM, &s_timer.next_time);
+
+    ESP_LOGI(TAG, "started (first target: %lld.%09ld)",
+             (long long)s_timer.next_time.tv_sec,
+             s_timer.next_time.tv_nsec);
+    return ESP_OK;
+}
+
+esp_err_t aes67_hw_timer_stop(void)
+{
+    s_timer.running = false;
+    ESP_LOGI(TAG, "stopped");
+    return ESP_OK;
+}

+ 361 - 0
components/aes67/src/aes67_l2tap_rx.c

@@ -0,0 +1,361 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * L2 TAP based RTP receiver.
+ *
+ * Reads raw Ethernet frames from the L2 TAP interface, parses
+ * Ethernet/IPv4/UDP/RTP headers, converts L24 audio to int16 mono,
+ * and writes to I2S with DMA-paced blocking. This bypasses lwIP
+ * entirely for minimum-latency audio reception.
+ */
+
+#include "aes67_l2tap_rx.h"
+#include "aes67_convert.h"
+
+#include <string.h>
+#include <inttypes.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <unistd.h>
+
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "esp_log.h"
+#include "esp_heap_caps.h"
+#include "esp_vfs_l2tap.h"
+#include "driver/i2s_common.h"
+
+static const char *TAG = "l2tap_rx";
+
+/* Ethernet header size (dst MAC + src MAC + EtherType) */
+#define ETH_HDR_LEN        14
+/* Minimum IPv4 header (no options) */
+#define IPV4_HDR_LEN       20
+/* UDP header size */
+#define UDP_HDR_LEN        8
+/* RTP header size (no CSRC, no extensions) */
+#define RTP_HDR_LEN        12
+
+/* Maximum Ethernet frame we expect (standard MTU) */
+#define MAX_FRAME_LEN      1600
+
+/* Offsets within the Ethernet frame */
+#define OFF_ETHERTYPE      12
+#define OFF_IP             ETH_HDR_LEN
+#define OFF_IP_PROTOCOL    (OFF_IP + 9)
+#define OFF_IP_DST         (OFF_IP + 16)
+#define OFF_UDP            (OFF_IP + IPV4_HDR_LEN)
+#define OFF_UDP_DST_PORT   (OFF_UDP + 2)
+#define OFF_RTP            (OFF_UDP + UDP_HDR_LEN)
+#define OFF_RTP_PAYLOAD    (OFF_RTP + RTP_HDR_LEN)
+
+/* Maximum frames per RTP packet (48kHz * 4ms = 192) */
+#define MAX_FRAMES_PER_PKT 192
+/* Stereo L24: 2 channels * 3 bytes = 6 bytes per frame */
+#define L24_FRAME_BYTES    6
+/* Stereo channels */
+#define NUM_CHANNELS       2
+
+/* Task parameters */
+#define L2TAP_RX_TASK_PRIO    23
+#define L2TAP_RX_TASK_CORE    0
+#define L2TAP_RX_TASK_STACK   8192
+
+/* Context passed to the receiver task */
+typedef struct {
+    i2s_chan_handle_t tx_chan;
+    uint16_t         rtp_port;
+} l2tap_rx_ctx_t;
+
+/*
+ * Read a big-endian uint16 from a byte pointer.
+ */
+static inline IRAM_ATTR uint16_t read_be16(const uint8_t *p)
+{
+    return (uint16_t)((p[0] << 8) | p[1]);
+}
+
+/*
+ * Check if an IPv4 address (in network byte order) is multicast (224.0.0.0/4).
+ * First byte in the range 224-239.
+ */
+static inline IRAM_ATTR bool is_ipv4_multicast(const uint8_t *ip)
+{
+    return (ip[0] & 0xF0) == 0xE0;
+}
+
+/*
+ * Parse and validate a raw Ethernet frame as an RTP packet.
+ * Returns pointer to the RTP payload and sets *payload_len,
+ * or NULL if the frame does not match our filter criteria.
+ */
+static IRAM_ATTR const uint8_t *parse_rtp_frame(
+    const uint8_t *frame, int frame_len,
+    uint16_t rtp_port, int *payload_len)
+{
+    /* Minimum frame: ETH + IP + UDP + RTP header */
+    if (frame_len < OFF_RTP_PAYLOAD) {
+        return NULL;
+    }
+
+    /* EtherType must be IPv4 (0x0800) */
+    if (read_be16(frame + OFF_ETHERTYPE) != 0x0800) {
+        return NULL;
+    }
+
+    /* IP protocol must be UDP (17) */
+    if (frame[OFF_IP_PROTOCOL] != 17) {
+        return NULL;
+    }
+
+    /* Must be IPv4 multicast destination */
+    if (!is_ipv4_multicast(frame + OFF_IP_DST)) {
+        return NULL;
+    }
+
+    /* Check IHL for actual IP header length (could have options) */
+    uint8_t ihl = (frame[OFF_IP] & 0x0F) * 4;
+    if (ihl < IPV4_HDR_LEN) {
+        return NULL;
+    }
+
+    /* Recalculate offsets if IP header has options */
+    int udp_off = OFF_IP + ihl;
+    int rtp_off = udp_off + UDP_HDR_LEN;
+    int payload_off = rtp_off + RTP_HDR_LEN;
+
+    if (frame_len < payload_off) {
+        return NULL;
+    }
+
+    /* UDP destination port must match our RTP port */
+    uint16_t dst_port = read_be16(frame + udp_off + 2);
+    if (dst_port != rtp_port) {
+        return NULL;
+    }
+
+    /* Validate RTP version (must be 2) */
+    uint8_t rtp_flags = frame[rtp_off];
+    if ((rtp_flags >> 6) != 2) {
+        return NULL;
+    }
+
+    /* Account for CSRC list if present */
+    uint8_t cc = rtp_flags & 0x0F;
+    payload_off += cc * 4;
+
+    /* Account for RTP header extension if present */
+    if (rtp_flags & 0x10) {
+        int ext_off = rtp_off + RTP_HDR_LEN + cc * 4;
+        if (frame_len < ext_off + 4) {
+            return NULL;
+        }
+        uint16_t ext_len = read_be16(frame + ext_off + 2);
+        payload_off += 4 + ext_len * 4;
+    }
+
+    if (payload_off >= frame_len) {
+        return NULL;
+    }
+
+    *payload_len = frame_len - payload_off;
+    return frame + payload_off;
+}
+
+/*
+ * Convert L24 stereo RTP payload to int16 mono.
+ *
+ * Steps:
+ *   1. Convert big-endian packed L24 to native int32 (via aes67_convert_from_net)
+ *   2. Mono mix: average L and R channels
+ *   3. Shift int32 down to int16
+ */
+static IRAM_ATTR void l24_stereo_to_i16_mono(
+    const uint8_t *payload, int payload_len,
+    int32_t *tmp32, int16_t *out16, int *out_frames)
+{
+    /* L24 stereo: 3 bytes * 2 channels = 6 bytes per frame */
+    int frame_count = payload_len / L24_FRAME_BYTES;
+    if (frame_count > MAX_FRAMES_PER_PKT) {
+        frame_count = MAX_FRAMES_PER_PKT;
+    }
+
+    /* Unpack big-endian L24 to native int32, interleaved stereo */
+    aes67_convert_from_net(payload, tmp32, frame_count, NUM_CHANNELS, 3);
+
+    /* Mono mix (L+R)/2 and convert int32 -> stereo int16 (L=R) */
+    for (int i = 0; i < frame_count; i++) {
+        int32_t left  = tmp32[i * 2];
+        int32_t right = tmp32[i * 2 + 1];
+        int32_t mono  = (left >> 1) + (right >> 1);
+        int16_t s16   = (int16_t)(mono >> 16);
+        out16[i * 2]     = s16;  /* Left channel */
+        out16[i * 2 + 1] = s16;  /* Right channel (same as left for mono) */
+    }
+
+    *out_frames = frame_count;
+}
+
+/*
+ * Open and configure the L2 TAP file descriptor for the ETH_0 interface.
+ * Returns the fd on success, -1 on failure.
+ */
+static int l2tap_open(void)
+{
+    int fd = open("/dev/net/tap", O_NONBLOCK);
+    if (fd < 0) {
+        ESP_LOGE(TAG, "failed to open /dev/net/tap: errno=%d", errno);
+        return -1;
+    }
+
+    /* Bind to the ETH_0 interface */
+    const char *ifname = "ETH_0";
+    if (ioctl(fd, L2TAP_S_INTF_DEVICE, ifname) < 0) {
+        ESP_LOGE(TAG, "L2TAP_S_INTF_DEVICE failed: errno=%d", errno);
+        close(fd);
+        return -1;
+    }
+
+    /* Filter for IPv4 frames only (EtherType 0x0800) */
+    uint16_t eth_type_filter = 0x0800;
+    if (ioctl(fd, L2TAP_S_RCV_FILTER, &eth_type_filter) < 0) {
+        ESP_LOGE(TAG, "L2TAP_S_RCV_FILTER failed: errno=%d", errno);
+        close(fd);
+        return -1;
+    }
+
+    ESP_LOGI(TAG, "L2 TAP fd=%d opened on %s, filter=0x%04x",
+             fd, ifname, eth_type_filter);
+    return fd;
+}
+
+/*
+ * Main L2 TAP receiver task. Reads raw Ethernet frames, filters for
+ * RTP multicast packets, converts audio, and writes to I2S.
+ */
+static void l2tap_rx_task(void *arg)
+{
+    l2tap_rx_ctx_t *ctx = (l2tap_rx_ctx_t *)arg;
+
+    /* Open L2 TAP interface */
+    int fd = l2tap_open();
+    if (fd < 0) {
+        ESP_LOGE(TAG, "L2 TAP open failed, task exiting");
+        heap_caps_free(ctx);
+        vTaskDelete(NULL);
+        return;
+    }
+
+    /* Allocate buffers from internal SRAM for cache-coherent DMA access */
+    uint8_t *frame_buf = heap_caps_malloc(MAX_FRAME_LEN, MALLOC_CAP_INTERNAL);
+    int32_t *tmp32_buf = heap_caps_malloc(
+        MAX_FRAMES_PER_PKT * NUM_CHANNELS * sizeof(int32_t), MALLOC_CAP_INTERNAL);
+    int16_t *pcm16_buf = heap_caps_malloc(
+        MAX_FRAMES_PER_PKT * NUM_CHANNELS * sizeof(int16_t), MALLOC_CAP_INTERNAL);
+
+    if (!frame_buf || !tmp32_buf || !pcm16_buf) {
+        ESP_LOGE(TAG, "buffer allocation failed");
+        goto cleanup;
+    }
+
+    ESP_LOGI(TAG, "receiver started, port=%u", ctx->rtp_port);
+
+    uint32_t pkt_count = 0;
+
+    while (1) {
+        int len = read(fd, frame_buf, MAX_FRAME_LEN);
+        if (len < 0) {
+            if (errno == EAGAIN || errno == EWOULDBLOCK) {
+                /* No data available, yield briefly */
+                vTaskDelay(1);
+                continue;
+            }
+            ESP_LOGE(TAG, "read error: errno=%d", errno);
+            vTaskDelay(pdMS_TO_TICKS(10));
+            continue;
+        }
+
+        if (len == 0) {
+            vTaskDelay(1);
+            continue;
+        }
+
+        /* Parse and filter the frame */
+        int payload_len = 0;
+        const uint8_t *payload = parse_rtp_frame(
+            frame_buf, len, ctx->rtp_port, &payload_len);
+
+        if (!payload || payload_len < L24_FRAME_BYTES) {
+            continue;
+        }
+
+        /* Convert L24 stereo to int16 mono */
+        int frame_count = 0;
+        l24_stereo_to_i16_mono(payload, payload_len,
+                               tmp32_buf, pcm16_buf, &frame_count);
+
+        if (frame_count <= 0) {
+            continue;
+        }
+
+        /* Write to I2S with DMA pacing (blocks until DMA descriptor is free).
+         * This blocking write at portMAX_DELAY is the 48kHz pacing mechanism. */
+        size_t bytes_written = 0;
+        i2s_channel_write(ctx->tx_chan, pcm16_buf,
+                          frame_count * NUM_CHANNELS * sizeof(int16_t),
+                          &bytes_written, portMAX_DELAY);
+
+        pkt_count++;
+        if ((pkt_count % 5000) == 0) {
+            ESP_LOGI(TAG, "rx packets: %"PRIu32, pkt_count);
+        }
+    }
+
+cleanup:
+    if (frame_buf)  heap_caps_free(frame_buf);
+    if (tmp32_buf)  heap_caps_free(tmp32_buf);
+    if (pcm16_buf)  heap_caps_free(pcm16_buf);
+    if (fd >= 0)    close(fd);
+    heap_caps_free(ctx);
+    vTaskDelete(NULL);
+}
+
+void aes67_l2tap_rx_start(i2s_chan_handle_t tx_chan, uint16_t rtp_port)
+{
+    if (!tx_chan) {
+        ESP_LOGE(TAG, "tx_chan is NULL");
+        return;
+    }
+
+    l2tap_rx_ctx_t *ctx = heap_caps_malloc(sizeof(l2tap_rx_ctx_t),
+                                           MALLOC_CAP_INTERNAL);
+    if (!ctx) {
+        ESP_LOGE(TAG, "failed to allocate context");
+        return;
+    }
+
+    ctx->tx_chan  = tx_chan;
+    ctx->rtp_port = rtp_port;
+
+    BaseType_t ret = xTaskCreatePinnedToCore(
+        l2tap_rx_task,
+        "l2tap_rx",
+        L2TAP_RX_TASK_STACK,
+        ctx,
+        L2TAP_RX_TASK_PRIO,
+        NULL,
+        L2TAP_RX_TASK_CORE);
+
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "failed to create l2tap_rx task");
+        heap_caps_free(ctx);
+        return;
+    }
+
+    ESP_LOGI(TAG, "L2 TAP RX task created (core %d, prio %d, port %u)",
+             L2TAP_RX_TASK_CORE, L2TAP_RX_TASK_PRIO, rtp_port);
+}

+ 87 - 0
components/aes67/src/aes67_mdns.c

@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * mDNS service advertisement for AES67/RAVENNA.
+ *
+ * Advertises this node as an AES67 device using DNS-SD/mDNS,
+ * making it discoverable by other AES67 devices on the network.
+ */
+
+#include "aes67_net.h"
+#include "aes67_config.h"
+
+#include <string.h>
+#include "esp_log.h"
+#include "mdns.h"
+
+static const char *TAG = "aes67_mdns";
+
+esp_err_t aes67_mdns_init(const char *node_name, uint16_t rtp_port)
+{
+    esp_err_t err = mdns_init();
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "mdns_init failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    err = mdns_hostname_set(node_name);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "mdns_hostname_set failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    err = mdns_instance_name_set(node_name);
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "mdns_instance_name_set failed: %s", esp_err_to_name(err));
+    }
+
+    /* Advertise as RAVENNA/AES67 device */
+    mdns_txt_item_t txt_items[] = {
+        { "proto", "AES67" },
+        { "rate",  "48000" },
+        { "ch",    "2" },
+    };
+
+    err = mdns_service_add(node_name, "_ravenna", "_udp",
+                           rtp_port, txt_items, 3);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "mdns_service_add _ravenna._udp failed: %s",
+                 esp_err_to_name(err));
+        return err;
+    }
+
+    /* Also advertise the rtsp service for SDP retrieval */
+    err = mdns_service_add(node_name, "_rtsp", "_tcp", 554, NULL, 0);
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "mdns_service_add _rtsp._tcp failed (non-critical)");
+    }
+
+    ESP_LOGI(TAG, "mDNS services registered: %s", node_name);
+    return ESP_OK;
+}
+
+esp_err_t aes67_mdns_update_txt(uint32_t sample_rate, uint8_t channels)
+{
+    char rate_str[12];
+    char ch_str[4];
+    snprintf(rate_str, sizeof(rate_str), "%lu", (unsigned long)sample_rate);
+    snprintf(ch_str, sizeof(ch_str), "%u", channels);
+
+    mdns_txt_item_t txt_items[] = {
+        { "proto", "AES67" },
+        { "rate",  rate_str },
+        { "ch",    ch_str },
+    };
+
+    return mdns_service_txt_set("_ravenna", "_udp", txt_items, 3);
+}
+
+void aes67_mdns_deinit(void)
+{
+    mdns_service_remove_all();
+    mdns_free();
+    ESP_LOGI(TAG, "mDNS services removed");
+}

+ 188 - 0
components/aes67/src/aes67_net.c

@@ -0,0 +1,188 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * Network utilities for AES67.
+ *
+ * Multicast socket management, IGMP, and IP helpers using lwIP on ESP-IDF.
+ */
+
+#include "aes67_net.h"
+
+#include <string.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <unistd.h>
+#include <errno.h>
+
+#include "esp_log.h"
+#include "esp_netif.h"
+
+static const char *TAG = "aes67_net";
+
+int aes67_net_create_udp_socket(uint16_t port, bool reuse)
+{
+    int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+    if (sock < 0) {
+        ESP_LOGE(TAG, "socket create failed: errno %d", errno);
+        return -1;
+    }
+
+    if (reuse) {
+        int opt = 1;
+        setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+    }
+
+    struct sockaddr_in addr = {
+        .sin_family = AF_INET,
+        .sin_port = htons(port),
+        .sin_addr.s_addr = htonl(INADDR_ANY),
+    };
+
+    if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+        ESP_LOGE(TAG, "bind to port %u failed: errno %d", port, errno);
+        close(sock);
+        return -1;
+    }
+
+    return sock;
+}
+
+esp_err_t aes67_net_join_multicast(int sock, const char *mcast_addr,
+                                   const char *if_addr)
+{
+    struct ip_mreq mreq;
+    mreq.imr_multiaddr.s_addr = inet_addr(mcast_addr);
+    mreq.imr_interface.s_addr = if_addr ? inet_addr(if_addr) : htonl(INADDR_ANY);
+
+    if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
+        ESP_LOGE(TAG, "join multicast %s failed: errno %d", mcast_addr, errno);
+        return ESP_FAIL;
+    }
+
+    ESP_LOGI(TAG, "joined multicast group %s", mcast_addr);
+    return ESP_OK;
+}
+
+esp_err_t aes67_net_leave_multicast(int sock, const char *mcast_addr,
+                                    const char *if_addr)
+{
+    struct ip_mreq mreq;
+    mreq.imr_multiaddr.s_addr = inet_addr(mcast_addr);
+    mreq.imr_interface.s_addr = if_addr ? inet_addr(if_addr) : htonl(INADDR_ANY);
+
+    if (setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
+        ESP_LOGW(TAG, "leave multicast %s failed: errno %d", mcast_addr, errno);
+        return ESP_FAIL;
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_net_set_multicast_ttl(int sock, uint8_t ttl)
+{
+    int val = ttl;
+    if (setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, &val, sizeof(val)) < 0) {
+        ESP_LOGE(TAG, "set multicast TTL failed: errno %d", errno);
+        return ESP_FAIL;
+    }
+
+    /* Bind multicast output to the local interface so sendto doesn't
+     * fail with EHOSTUNREACH when the default route changes. */
+    uint32_t local_ip = 0;
+    aes67_net_get_local_ip(&local_ip);
+    struct in_addr mcast_if;
+    mcast_if.s_addr = local_ip;
+    if (setsockopt(sock, IPPROTO_IP, IP_MULTICAST_IF, &mcast_if, sizeof(mcast_if)) < 0) {
+        ESP_LOGW(TAG, "set IP_MULTICAST_IF failed: errno %d", errno);
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_net_set_dscp(int sock, uint8_t dscp)
+{
+    /* TOS field = DSCP << 2 */
+    int tos = dscp << 2;
+    if (setsockopt(sock, IPPROTO_IP, IP_TOS, &tos, sizeof(tos)) < 0) {
+        ESP_LOGE(TAG, "set DSCP failed: errno %d", errno);
+        return ESP_FAIL;
+    }
+    return ESP_OK;
+}
+
+esp_err_t aes67_net_set_buffers(int sock, int send_size, int recv_size)
+{
+    if (send_size > 0) {
+        setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &send_size, sizeof(send_size));
+    }
+    if (recv_size > 0) {
+        setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recv_size, sizeof(recv_size));
+    }
+    return ESP_OK;
+}
+
+uint32_t aes67_net_ip_to_u32(const char *ip_str)
+{
+    return inet_addr(ip_str);
+}
+
+void aes67_net_u32_to_ip(uint32_t ip, char *buf, size_t len)
+{
+    struct in_addr addr = { .s_addr = ip };
+    const char *str = inet_ntoa(addr);
+    if (str) {
+        strncpy(buf, str, len - 1);
+        buf[len - 1] = '\0';
+    }
+}
+
+bool aes67_net_is_multicast(uint32_t ip)
+{
+    /* Multicast: 224.0.0.0 - 239.255.255.255 (0xE0000000 - 0xEFFFFFFF in host order) */
+    uint32_t host_ip = ntohl(ip);
+    return (host_ip & 0xF0000000) == 0xE0000000;
+}
+
+esp_err_t aes67_net_get_local_ip(uint32_t *ip)
+{
+    esp_netif_t *netif = esp_netif_get_handle_from_ifkey("ETH_0");
+    if (!netif) {
+        ESP_LOGE(TAG, "no ethernet netif found");
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    esp_netif_ip_info_t ip_info;
+    esp_err_t err = esp_netif_get_ip_info(netif, &ip_info);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    *ip = ip_info.ip.addr;
+    return ESP_OK;
+}
+
+esp_err_t aes67_net_get_local_mac(uint8_t mac[6])
+{
+    esp_netif_t *netif = esp_netif_get_handle_from_ifkey("ETH_0");
+    if (!netif) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    return esp_netif_get_mac(netif, mac);
+}
+
+void aes67_net_mcast_ip_to_mac(uint32_t mcast_ip, uint8_t mac[6])
+{
+    /* IANA multicast MAC: 01:00:5E:xx:xx:xx where lower 23 bits of IP */
+    uint32_t ip_host = ntohl(mcast_ip);
+    mac[0] = 0x01;
+    mac[1] = 0x00;
+    mac[2] = 0x5E;
+    mac[3] = (ip_host >> 16) & 0x7F;
+    mac[4] = (ip_host >> 8) & 0xFF;
+    mac[5] = ip_host & 0xFF;
+}

+ 507 - 0
components/aes67/src/aes67_ptp.c

@@ -0,0 +1,507 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * PTP (IEEE 1588) clock synchronization implementation.
+ *
+ * Wraps the ptpd daemon and ESP32-P4 hardware timestamping to provide
+ * a synchronized PTP clock for AES67 RTP timestamp generation.
+ * Monitors lock state and notifies upper layers on transitions.
+ */
+
+#include "aes67_ptp.h"
+
+#include <string.h>
+#include <stdlib.h>
+#include <time.h>
+
+#include "esp_log.h"
+#include "esp_err.h"
+#include "esp_check.h"
+#include "esp_eth.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+static const char *TAG = "aes67_ptp";
+
+/* Thresholds for lock state machine */
+#define PTP_LOCKING_THRESHOLD_NS    (10 * 1000 * 1000)   /* 10 ms  */
+#define PTP_LOCKED_THRESHOLD_NS     (1000)                /* 1 us   */
+#define PTP_LOCKED_STABLE_COUNT     4                     /* iterations at < 1us before locked */
+#define PTP_MONITOR_INTERVAL_MS     500
+#define PTP_MONITOR_STACK_SIZE      4096
+#define PTP_MONITOR_PRIORITY        10  /* Above SAP, below RTP/audio */
+#define PTP_INTERFACE_NAME          "ETH_0"
+
+/* PTP daemon and clock APIs from local components */
+#include "ptpd.h"
+#include "esp_eth_time.h"
+#include "esp_vfs_l2tap.h"
+
+/* -------------------------------------------------------------------
+ * Internal context
+ * ------------------------------------------------------------------- */
+struct aes67_ptp_ctx {
+    esp_eth_handle_t eth_handle;
+    int ptpd_pid;
+    aes67_ptp_config_t config;
+    aes67_ptp_lock_state_t lock_state;
+    aes67_ptp_lock_cb_t lock_cb;
+    void *lock_cb_user_data;
+    TaskHandle_t monitor_task;
+    bool running;
+
+    /* Grandmaster identity from last status poll */
+    uint8_t grandmaster_id[8];
+
+    /* Own MAC address, used for generating grandmaster identity when acting as GM */
+    uint8_t own_mac[6];
+    bool is_grandmaster;            /* True when this node is acting as PTP GM */
+
+    /* Rolling count of consecutive samples under the locked threshold */
+    int stable_count;
+
+    /* Cached status values */
+    int32_t offset_ns;
+    int32_t path_delay_ns;
+    int32_t jitter_ns;      /* drift rate in ppb, used as jitter indicator */
+
+    /* Stats logging counter (prints every N polls) */
+    int stats_counter;
+};
+
+/* -------------------------------------------------------------------
+ * Lock state machine
+ * ------------------------------------------------------------------- */
+
+/*
+ * Evaluate offset and advance the lock state machine.
+ * Returns true if the state changed.
+ */
+static bool ptp_update_lock_state(struct aes67_ptp_ctx *ctx, int64_t offset_ns)
+{
+    int64_t abs_offset = (offset_ns < 0) ? -offset_ns : offset_ns;
+    aes67_ptp_lock_state_t prev = ctx->lock_state;
+
+    switch (ctx->lock_state) {
+    case AES67_PTP_UNLOCKED:
+        if (abs_offset < PTP_LOCKING_THRESHOLD_NS) {
+            ctx->lock_state = AES67_PTP_LOCKING;
+            ctx->stable_count = 0;
+            ESP_LOGI(TAG, "PTP locking (offset %lld ns)", (long long)offset_ns);
+        }
+        break;
+
+    case AES67_PTP_LOCKING:
+        if (abs_offset >= PTP_LOCKING_THRESHOLD_NS) {
+            /* Lost convergence, fall back */
+            ctx->lock_state = AES67_PTP_UNLOCKED;
+            ctx->stable_count = 0;
+            ESP_LOGW(TAG, "PTP lost lock (offset %lld ns)", (long long)offset_ns);
+        } else if (abs_offset < PTP_LOCKED_THRESHOLD_NS) {
+            ctx->stable_count++;
+            if (ctx->stable_count >= PTP_LOCKED_STABLE_COUNT) {
+                ctx->lock_state = AES67_PTP_LOCKED;
+                ESP_LOGI(TAG, "PTP locked (offset %lld ns after %d stable polls)",
+                         (long long)offset_ns, ctx->stable_count);
+            }
+        } else {
+            /* Still converging but not yet sub-microsecond */
+            ctx->stable_count = 0;
+        }
+        break;
+
+    case AES67_PTP_LOCKED:
+        if (abs_offset >= PTP_LOCKING_THRESHOLD_NS) {
+            ctx->lock_state = AES67_PTP_UNLOCKED;
+            ctx->stable_count = 0;
+            ESP_LOGW(TAG, "PTP lost lock completely (offset %lld ns)", (long long)offset_ns);
+        } else if (abs_offset >= PTP_LOCKED_THRESHOLD_NS) {
+            ctx->lock_state = AES67_PTP_LOCKING;
+            ctx->stable_count = 0;
+            ESP_LOGW(TAG, "PTP drifting (offset %lld ns, re-locking)", (long long)offset_ns);
+        }
+        break;
+    }
+
+    return (ctx->lock_state != prev);
+}
+
+/* -------------------------------------------------------------------
+ * Monitor task
+ * ------------------------------------------------------------------- */
+
+static void ptp_monitor_task(void *arg)
+{
+    struct aes67_ptp_ctx *ctx = (struct aes67_ptp_ctx *)arg;
+    struct ptpd_status_s status;
+
+    ESP_LOGI(TAG, "PTP monitor task started");
+
+    while (ctx->running) {
+        vTaskDelay(pdMS_TO_TICKS(PTP_MONITOR_INTERVAL_MS));
+
+        if (!ctx->running) {
+            break;
+        }
+
+        memset(&status, 0, sizeof(status));
+        if (ctx->ptpd_pid < 0) {
+            continue;
+        }
+        int ret = ptpd_status(ctx->ptpd_pid, &status);
+        if (ret != 0) {
+            ESP_LOGD(TAG, "ptpd_status returned %d, daemon may still be initializing", ret);
+            continue;
+        }
+
+        /* Cache latest offset and path delay.
+         * last_delta_ns is the measured clock error from the most recent sync. */
+        ctx->offset_ns = (int32_t)status.last_delta_ns;
+        ctx->path_delay_ns = (int32_t)status.path_delay_ns;
+        ctx->jitter_ns = (int32_t)status.drift_ppb;
+
+        /* Extract grandmaster identity from status struct */
+        memcpy(ctx->grandmaster_id, status.clock_source_info.gm_id, 8);
+
+        /* Detect if we are the grandmaster. When acting as GM, the ptpd daemon
+         * will report no valid clock source, or the GM ID will match our own
+         * EUI-64 (MAC with FF:FE inserted in the middle). */
+        bool is_self_gm = !status.clock_source_valid;
+        if (!is_self_gm) {
+            is_self_gm = (
+            ctx->grandmaster_id[0] == ctx->own_mac[0] &&
+            ctx->grandmaster_id[1] == ctx->own_mac[1] &&
+            ctx->grandmaster_id[2] == ctx->own_mac[2] &&
+            ctx->grandmaster_id[3] == 0xFF &&
+            ctx->grandmaster_id[4] == 0xFE &&
+            ctx->grandmaster_id[5] == ctx->own_mac[3] &&
+            ctx->grandmaster_id[6] == ctx->own_mac[4] &&
+            ctx->grandmaster_id[7] == ctx->own_mac[5]);
+        }
+
+        if (is_self_gm) {
+
+            if (!ctx->is_grandmaster) {
+                ctx->is_grandmaster = true;
+                ESP_LOGI(TAG, "This node is PTP grandmaster "
+                         "(GM ID: %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X)",
+                         ctx->own_mac[0], ctx->own_mac[1], ctx->own_mac[2],
+                         0xFF, 0xFE,
+                         ctx->own_mac[3], ctx->own_mac[4], ctx->own_mac[5]);
+            }
+
+            /* When acting as grandmaster, we are always "locked" to our own clock */
+            if (ctx->lock_state != AES67_PTP_LOCKED) {
+                aes67_ptp_lock_state_t prev = ctx->lock_state;
+                ctx->lock_state = AES67_PTP_LOCKED;
+                ctx->offset_ns = 0;
+                ctx->path_delay_ns = 0;
+                if (prev != AES67_PTP_LOCKED && ctx->lock_cb) {
+                    ctx->lock_cb(ctx->lock_state, ctx->lock_cb_user_data);
+                }
+            }
+        } else {
+            if (ctx->is_grandmaster) {
+                ctx->is_grandmaster = false;
+                ESP_LOGI(TAG, "External grandmaster detected, switching to slave mode "
+                         "(GM ID: %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X)",
+                         ctx->grandmaster_id[0], ctx->grandmaster_id[1],
+                         ctx->grandmaster_id[2], ctx->grandmaster_id[3],
+                         ctx->grandmaster_id[4], ctx->grandmaster_id[5],
+                         ctx->grandmaster_id[6], ctx->grandmaster_id[7]);
+            }
+
+            /* Run lock state machine for slave mode */
+            bool changed = ptp_update_lock_state(ctx, status.last_delta_ns);
+            if (changed && ctx->lock_cb) {
+                ctx->lock_cb(ctx->lock_state, ctx->lock_cb_user_data);
+            }
+        }
+
+        /* Periodic stats log every 20 polls (~10 seconds) */
+        ctx->stats_counter++;
+        if (ctx->stats_counter >= 20) {
+            ctx->stats_counter = 0;
+
+            const char *role = ctx->is_grandmaster ? "master" : "slave";
+            const char *lock = (ctx->lock_state == AES67_PTP_LOCKED) ? "locked" :
+                               (ctx->lock_state == AES67_PTP_LOCKING) ? "locking" :
+                               "unlocked";
+
+            if (ctx->is_grandmaster) {
+                ESP_LOGI(TAG, "[%s/%s] GM ID: %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+                         role, lock,
+                         ctx->own_mac[0], ctx->own_mac[1], ctx->own_mac[2],
+                         0xFF, 0xFE,
+                         ctx->own_mac[3], ctx->own_mac[4], ctx->own_mac[5]);
+            } else {
+                ESP_LOGI(TAG, "[%s/%s] offset: %+ld ns, delay: %ld ns, drift: %+ld ppb, "
+                         "GM: %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+                         role, lock,
+                         (long)ctx->offset_ns, (long)ctx->path_delay_ns,
+                         (long)ctx->jitter_ns,
+                         ctx->grandmaster_id[0], ctx->grandmaster_id[1],
+                         ctx->grandmaster_id[2], ctx->grandmaster_id[3],
+                         ctx->grandmaster_id[4], ctx->grandmaster_id[5],
+                         ctx->grandmaster_id[6], ctx->grandmaster_id[7]);
+            }
+        }
+    }
+
+    ESP_LOGI(TAG, "PTP monitor task exiting");
+    vTaskDelete(NULL);
+}
+
+/* -------------------------------------------------------------------
+ * Public API
+ * ------------------------------------------------------------------- */
+
+esp_err_t aes67_ptp_init(esp_eth_handle_t eth_handle, const aes67_ptp_config_t *config,
+                         aes67_ptp_handle_t *handle)
+{
+    ESP_RETURN_ON_FALSE(eth_handle != NULL, ESP_ERR_INVALID_ARG, TAG, "eth_handle is NULL");
+    ESP_RETURN_ON_FALSE(config != NULL, ESP_ERR_INVALID_ARG, TAG, "config is NULL");
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+
+    struct aes67_ptp_ctx *ctx = calloc(1, sizeof(struct aes67_ptp_ctx));
+    ESP_RETURN_ON_FALSE(ctx != NULL, ESP_ERR_NO_MEM, TAG, "failed to allocate PTP context");
+
+    ctx->eth_handle = eth_handle;
+    ctx->config = *config;
+    ctx->lock_state = AES67_PTP_UNLOCKED;
+    ctx->ptpd_pid = -1;
+    ctx->running = false;
+    ctx->is_grandmaster = false;
+
+    /* Read the MAC address for grandmaster identity detection.
+     * The EUI-64 GM ID is derived by inserting FF:FE into the MAC. */
+    esp_eth_ioctl(eth_handle, ETH_CMD_G_MAC_ADDR, ctx->own_mac);
+
+    /* Register the L2 TAP VFS interface. The ptpd component opens
+     * /dev/net/tap for Layer 2 PTP packet transport with hardware
+     * timestamping. This must happen before ptpd_start(). */
+    esp_err_t ret = esp_vfs_l2tap_intf_register(NULL);
+    if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
+        /* ESP_ERR_INVALID_STATE means already registered, which is fine */
+        ESP_LOGE(TAG, "esp_vfs_l2tap_intf_register failed: %s", esp_err_to_name(ret));
+        free(ctx);
+        return ret;
+    }
+
+    /* Initialize the hardware PTP clock tied to the EMAC */
+    esp_eth_clock_cfg_t clk_cfg = {
+        .eth_hndl = eth_handle,
+    };
+    ret = esp_eth_clock_init(CLOCK_PTP_SYSTEM, &clk_cfg);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "esp_eth_clock_init failed: %s", esp_err_to_name(ret));
+        free(ctx);
+        return ret;
+    }
+
+    ESP_LOGI(TAG, "PTP initialized (domain %u, own ID: %02X-%02X-%02X-FF-FE-%02X-%02X-%02X)",
+             config->domain,
+             ctx->own_mac[0], ctx->own_mac[1], ctx->own_mac[2],
+             ctx->own_mac[3], ctx->own_mac[4], ctx->own_mac[5]);
+    *handle = ctx;
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_start(aes67_ptp_handle_t handle)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(!handle->running, ESP_ERR_INVALID_STATE, TAG, "PTP already running");
+
+    /* Start the ptpd daemon. The interface name passed here must match
+     * what the ESP netif was created with. For default ethernet config
+     * this is typically "ETH_DEF". */
+    int pid = ptpd_start(PTP_INTERFACE_NAME);
+    if (pid < 0) {
+        ESP_LOGE(TAG, "ptpd_start failed (returned %d)", pid);
+        return ESP_FAIL;
+    }
+
+    handle->ptpd_pid = pid;
+    handle->running = true;
+    handle->lock_state = AES67_PTP_UNLOCKED;
+    handle->stable_count = 0;
+
+    ESP_LOGI(TAG, "ptpd started (pid %d)", pid);
+
+    /* Spawn monitor task to track lock state */
+    BaseType_t xret = xTaskCreate(
+        ptp_monitor_task,
+        "ptp_monitor",
+        PTP_MONITOR_STACK_SIZE,
+        handle,
+        PTP_MONITOR_PRIORITY,
+        &handle->monitor_task
+    );
+    if (xret != pdPASS) {
+        ESP_LOGE(TAG, "failed to create PTP monitor task");
+        ptpd_stop(handle->ptpd_pid);
+        handle->running = false;
+        handle->ptpd_pid = -1;
+        return ESP_ERR_NO_MEM;
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_stop(aes67_ptp_handle_t handle)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(handle->running, ESP_ERR_INVALID_STATE, TAG, "PTP not running");
+
+    /* Signal the monitor task to stop, then wait for it to exit */
+    handle->running = false;
+
+    /* Give the monitor task time to notice the flag and exit */
+    vTaskDelay(pdMS_TO_TICKS(PTP_MONITOR_INTERVAL_MS + 100));
+    handle->monitor_task = NULL;
+
+    /* Stop the ptpd daemon */
+    int ret = ptpd_stop(handle->ptpd_pid);
+    if (ret != 0) {
+        ESP_LOGW(TAG, "ptpd_stop returned %d", ret);
+    }
+    handle->ptpd_pid = -1;
+
+    /* Reset state */
+    aes67_ptp_lock_state_t prev_state = handle->lock_state;
+    handle->lock_state = AES67_PTP_UNLOCKED;
+    handle->stable_count = 0;
+    handle->offset_ns = 0;
+    handle->path_delay_ns = 0;
+
+    /* Notify if state changed */
+    if (prev_state != AES67_PTP_UNLOCKED && handle->lock_cb) {
+        handle->lock_cb(AES67_PTP_UNLOCKED, handle->lock_cb_user_data);
+    }
+
+    ESP_LOGI(TAG, "PTP stopped");
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_destroy(aes67_ptp_handle_t handle)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+
+    /* Stop first if still running */
+    if (handle->running) {
+        esp_err_t ret = aes67_ptp_stop(handle);
+        if (ret != ESP_OK) {
+            ESP_LOGW(TAG, "aes67_ptp_stop returned %s during destroy", esp_err_to_name(ret));
+        }
+    }
+
+    free(handle);
+    ESP_LOGI(TAG, "PTP context destroyed");
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_get_status(aes67_ptp_handle_t handle, aes67_ptp_status_t *status)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(status != NULL, ESP_ERR_INVALID_ARG, TAG, "status is NULL");
+
+    status->lock_state = handle->lock_state;
+    status->domain = handle->config.domain;
+    status->offset_ns = handle->offset_ns;
+    status->path_delay_ns = handle->path_delay_ns;
+    status->jitter_ns = handle->jitter_ns;
+
+    /* When acting as grandmaster, report our own EUI-64 as the GM ID */
+    if (handle->is_grandmaster) {
+        status->grandmaster_id[0] = handle->own_mac[0];
+        status->grandmaster_id[1] = handle->own_mac[1];
+        status->grandmaster_id[2] = handle->own_mac[2];
+        status->grandmaster_id[3] = 0xFF;
+        status->grandmaster_id[4] = 0xFE;
+        status->grandmaster_id[5] = handle->own_mac[3];
+        status->grandmaster_id[6] = handle->own_mac[4];
+        status->grandmaster_id[7] = handle->own_mac[5];
+    } else {
+        memcpy(status->grandmaster_id, handle->grandmaster_id, 8);
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_get_time_ns(aes67_ptp_handle_t handle, uint64_t *time_ns)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(time_ns != NULL, ESP_ERR_INVALID_ARG, TAG, "time_ns is NULL");
+
+    struct timespec ts;
+    int ret = esp_eth_clock_gettime(CLOCK_PTP_SYSTEM, &ts);
+    if (ret != 0) {
+        ESP_LOGE(TAG, "esp_eth_clock_gettime failed (%d)", ret);
+        return ESP_FAIL;
+    }
+
+    *time_ns = (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_get_time(aes67_ptp_handle_t handle, uint32_t *sec, uint32_t *nsec)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(sec != NULL && nsec != NULL, ESP_ERR_INVALID_ARG, TAG, "output is NULL");
+
+    struct timespec ts;
+    int ret = esp_eth_clock_gettime(CLOCK_PTP_SYSTEM, &ts);
+    if (ret != 0) {
+        ESP_LOGE(TAG, "esp_eth_clock_gettime failed (%d)", ret);
+        return ESP_FAIL;
+    }
+
+    *sec = (uint32_t)ts.tv_sec;
+    *nsec = (uint32_t)ts.tv_nsec;
+    return ESP_OK;
+}
+
+uint32_t aes67_ptp_time_to_rtp_ts(uint64_t ptp_time_ns, uint32_t sample_rate)
+{
+    /*
+     * RTP timestamp = (ptp_time_ns * sample_rate) / 1_000_000_000
+     *
+     * To avoid 128-bit overflow we split the computation:
+     *   seconds part: (ptp_time_ns / 1e9) * sample_rate
+     *   sub-second part: (ptp_time_ns % 1e9) * sample_rate / 1e9
+     *
+     * The result is taken modulo 2^32 (implicit uint32_t wrap).
+     */
+    uint64_t sec = ptp_time_ns / 1000000000ULL;
+    uint64_t nsec = ptp_time_ns % 1000000000ULL;
+
+    uint64_t ts = (sec * sample_rate) + (nsec * sample_rate / 1000000000ULL);
+    return (uint32_t)ts;
+}
+
+esp_err_t aes67_ptp_register_lock_cb(aes67_ptp_handle_t handle,
+                                     aes67_ptp_lock_cb_t cb, void *user_data)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+
+    handle->lock_cb = cb;
+    handle->lock_cb_user_data = user_data;
+    return ESP_OK;
+}
+
+esp_err_t aes67_ptp_get_grandmaster_str(aes67_ptp_handle_t handle, char *buf, size_t len)
+{
+    ESP_RETURN_ON_FALSE(handle != NULL, ESP_ERR_INVALID_ARG, TAG, "handle is NULL");
+    ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_INVALID_ARG, TAG, "buf is NULL");
+    ESP_RETURN_ON_FALSE(len >= 24, ESP_ERR_INVALID_ARG, TAG,
+                        "buf too small (need 24 bytes for \"XX-XX-XX-XX-XX-XX-XX-XX\")");
+
+    const uint8_t *gm = handle->grandmaster_id;
+    snprintf(buf, len, "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+             gm[0], gm[1], gm[2], gm[3], gm[4], gm[5], gm[6], gm[7]);
+
+    return ESP_OK;
+}

+ 1171 - 0
components/aes67/src/aes67_rtp.c

@@ -0,0 +1,1171 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * RTP audio streaming engine implementation.
+ *
+ * Manages source (TX) and sink (RX) streams with per-stream ring buffers.
+ * The RX task receives packets and dispatches to sink jitter buffers.
+ * The TX path is driven by the audio frame timer calling process_tx().
+ *
+ * Ring buffers use single-reader/single-writer lock-free design with
+ * volatile read/write positions for safe concurrent access.
+ */
+
+#include "aes67_rtp.h"
+#include "aes67_config.h"
+#include "aes67_convert.h"
+#include "aes67_net.h"
+#include <string.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <errno.h>
+#include "esp_log.h"
+#include "esp_heap_caps.h"
+#include "lwip/udp.h"
+#include "lwip/pbuf.h"
+#include "lwip/igmp.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/stream_buffer.h"
+
+static const char *TAG = "aes67_rtp";
+
+#define AES67_RTP_VERSION       2
+#define AES67_RTP_HEADER_SIZE   12
+#define AES67_MAX_STREAMS       (CONFIG_AES67_MAX_SOURCES + CONFIG_AES67_MAX_SINKS)
+#define AES67_RX_TASK_STACK     4096
+#define AES67_RX_BUF_SIZE       2048
+#define AES67_JITTER_BUF_MULT   6   /* 6 * packet_size for jitter absorption */
+
+/* Ring buffer for audio data. Single reader, single writer. */
+typedef struct {
+    uint8_t *data;
+    uint32_t size;              /* Total buffer size in bytes */
+    volatile uint32_t write_pos;
+    volatile uint32_t read_pos;
+} aes67_ringbuf_t;
+
+/* Internal stream structure */
+struct aes67_rtp_stream {
+    aes67_rtp_stream_config_t config;
+    aes67_stream_params_t params;       /* Computed stream parameters */
+    aes67_stream_dir_t direction;
+    int tx_sock;
+    struct sockaddr_in dest_addr;
+    uint16_t seq_num;
+    uint32_t ssrc;
+    uint32_t rtp_timestamp;     /* Running RTP timestamp, increments by samples_per_packet */
+    bool timestamp_initialized; /* Set once from PTP time on first TX */
+    aes67_rtp_stream_status_t status;
+    aes67_ringbuf_t ring;
+    uint32_t samples_per_packet;
+    uint32_t packet_payload_size;
+    uint8_t *tx_packet_buf;     /* Scratch buffer for packet assembly (internal SRAM) */
+    bool active;
+    bool first_packet;          /* True until first RTP packet received (sinks) */
+    /* Codec conversion function pointers (MTConvert pattern).
+     * Resolved once at stream creation, called per-packet with no branching. */
+    aes67_convert_to_net_fn convert_to_net;
+    aes67_convert_from_net_fn convert_from_net;
+    /* Jitter measurement for RX */
+    int64_t last_rx_time_us;    /* Timestamp of last received packet in microseconds */
+    int32_t jitter_us;          /* Estimated inter-packet jitter in microseconds */
+};
+
+/* Internal engine structure */
+struct aes67_rtp_engine {
+    struct aes67_rtp_stream *streams[AES67_MAX_STREAMS];
+    int stream_count;
+    int rx_sock;
+    TaskHandle_t rx_task;
+    bool running;
+    bool use_psram;
+    aes67_net_config_t net_config;
+    /* Direct I2S playback from RX task */
+    bool i2s_playback_enabled;
+    void *audio_handle;
+    /* Task to notify when new sink data arrives */
+    TaskHandle_t playback_notify_task;
+    /* Stream buffer for decoupling RTP RX from I2S DMA */
+    StreamBufferHandle_t audio_stream_buf;
+    /* Raw lwIP UDP PCB -- processes packets directly in lwIP context,
+     * bypassing socket layer and its ~412us per-packet overhead. */
+    struct udp_pcb *raw_pcb;
+    int32_t *raw_sample_buf;  /* Pre-allocated conversion buffer */
+};
+
+/* --- Ring buffer helpers --- */
+
+/* Available bytes to read */
+static inline uint32_t ringbuf_available(const aes67_ringbuf_t *rb)
+{
+    uint32_t wp = rb->write_pos;
+    uint32_t rp = rb->read_pos;
+    if (wp >= rp) {
+        return wp - rp;
+    }
+    return rb->size - rp + wp;
+}
+
+/* Free space available for writing */
+static inline uint32_t ringbuf_free(const aes67_ringbuf_t *rb)
+{
+    /* Reserve one byte to distinguish full from empty */
+    uint32_t avail = ringbuf_available(rb);
+    return rb->size - 1 - avail;
+}
+
+/* Write data into ring buffer. Returns bytes actually written. */
+static uint32_t ringbuf_write(aes67_ringbuf_t *rb, const uint8_t *data, uint32_t len)
+{
+    uint32_t free_bytes = ringbuf_free(rb);
+    if (len > free_bytes) {
+        len = free_bytes;
+    }
+    if (len == 0) {
+        return 0;
+    }
+
+    uint32_t wp = rb->write_pos;
+    uint32_t first = rb->size - wp;
+    if (first >= len) {
+        memcpy(rb->data + wp, data, len);
+    } else {
+        memcpy(rb->data + wp, data, first);
+        memcpy(rb->data, data + first, len - first);
+    }
+
+    /* Atomic update of write position */
+    rb->write_pos = (wp + len) % rb->size;
+    return len;
+}
+
+/* Read data from ring buffer. Returns bytes actually read. */
+static uint32_t ringbuf_read(aes67_ringbuf_t *rb, uint8_t *data, uint32_t len)
+{
+    uint32_t avail = ringbuf_available(rb);
+    if (len > avail) {
+        len = avail;
+    }
+    if (len == 0) {
+        return 0;
+    }
+
+    uint32_t rp = rb->read_pos;
+    uint32_t first = rb->size - rp;
+    if (first >= len) {
+        memcpy(data, rb->data + rp, len);
+    } else {
+        memcpy(data, rb->data + rp, first);
+        memcpy(data + first, rb->data, len - first);
+    }
+
+    /* Atomic update of read position */
+    rb->read_pos = (rp + len) % rb->size;
+    return len;
+}
+
+/* --- RTP header construction/parsing --- */
+
+static void rtp_build_header(uint8_t *buf, uint8_t pt, uint16_t seq,
+                             uint32_t timestamp, uint32_t ssrc)
+{
+    buf[0] = (AES67_RTP_VERSION << 6); /* V=2, no padding, no extension, CC=0 */
+    buf[1] = pt & 0x7F;                /* No marker bit */
+    buf[2] = (seq >> 8) & 0xFF;
+    buf[3] = seq & 0xFF;
+    buf[4] = (timestamp >> 24) & 0xFF;
+    buf[5] = (timestamp >> 16) & 0xFF;
+    buf[6] = (timestamp >> 8) & 0xFF;
+    buf[7] = timestamp & 0xFF;
+    buf[8] = (ssrc >> 24) & 0xFF;
+    buf[9] = (ssrc >> 16) & 0xFF;
+    buf[10] = (ssrc >> 8) & 0xFF;
+    buf[11] = ssrc & 0xFF;
+}
+
+static bool rtp_parse_header(const uint8_t *buf, size_t len,
+                             aes67_rtp_header_t *hdr)
+{
+    if (len < AES67_RTP_HEADER_SIZE) {
+        return false;
+    }
+
+    uint8_t version = (buf[0] >> 6) & 0x03;
+    if (version != AES67_RTP_VERSION) {
+        return false;
+    }
+
+    hdr->flags = buf[0];
+    hdr->pt = buf[1] & 0x7F;
+    hdr->seq = (uint16_t)(buf[2] << 8) | buf[3];
+    hdr->timestamp = ((uint32_t)buf[4] << 24) | ((uint32_t)buf[5] << 16) |
+                     ((uint32_t)buf[6] << 8) | buf[7];
+    hdr->ssrc = ((uint32_t)buf[8] << 24) | ((uint32_t)buf[9] << 16) |
+                ((uint32_t)buf[10] << 8) | buf[11];
+    return true;
+}
+
+/* --- Stream allocation helpers --- */
+
+static esp_err_t stream_alloc_buffers(struct aes67_rtp_stream *stream,
+                                      bool use_psram)
+{
+    uint32_t buf_size;
+
+    if (stream->direction == AES67_STREAM_SINK) {
+        /* Sink ring buffer stores int32_t samples (4 bytes each) regardless
+         * of the wire word length. Jitter buffer: 4x packet size. */
+        uint32_t frame_size = stream->config.channels * sizeof(int32_t);
+        uint32_t packet_bytes = stream->samples_per_packet * frame_size;
+        buf_size = packet_bytes * AES67_JITTER_BUF_MULT;
+    } else {
+        /* Source TX buffer stores int32_t samples (4 bytes each).
+         * The convert-to-net step packs them into word_length on send. */
+        uint32_t frame_size = stream->config.channels * sizeof(int32_t);
+        uint32_t packet_bytes = stream->samples_per_packet * frame_size;
+        buf_size = packet_bytes * AES67_JITTER_BUF_MULT;
+    }
+
+    /* Add 1 byte for ring buffer full/empty distinction */
+    buf_size += 1;
+
+    /* Always use internal SRAM for jitter buffer -- PSRAM reads add
+     * latency that causes the 10% throughput loss in i2s_channel_write path */
+    uint32_t caps = MALLOC_CAP_INTERNAL;
+    stream->ring.data = heap_caps_malloc(buf_size, caps);
+    if (!stream->ring.data) {
+        ESP_LOGE(TAG, "Failed to allocate ring buffer (%lu bytes)", (unsigned long)buf_size);
+        return ESP_ERR_NO_MEM;
+    }
+    memset(stream->ring.data, 0, buf_size);
+    stream->ring.size = buf_size;
+    stream->ring.write_pos = 0;
+    stream->ring.read_pos = 0;
+
+    /* TX packet scratch buffer always in internal SRAM for DMA access */
+    if (stream->direction == AES67_STREAM_SOURCE) {
+        uint32_t pkt_size = AES67_RTP_HEADER_SIZE + stream->packet_payload_size;
+        stream->tx_packet_buf = heap_caps_malloc(pkt_size,
+                                                 MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
+        if (!stream->tx_packet_buf) {
+            ESP_LOGE(TAG, "Failed to allocate TX packet buffer (%lu bytes)",
+                     (unsigned long)pkt_size);
+            heap_caps_free(stream->ring.data);
+            stream->ring.data = NULL;
+            return ESP_ERR_NO_MEM;
+        }
+    }
+
+    return ESP_OK;
+}
+
+static void stream_free_buffers(struct aes67_rtp_stream *stream)
+{
+    if (stream->ring.data) {
+        heap_caps_free(stream->ring.data);
+        stream->ring.data = NULL;
+    }
+    if (stream->tx_packet_buf) {
+        heap_caps_free(stream->tx_packet_buf);
+        stream->tx_packet_buf = NULL;
+    }
+}
+
+/* Find a sink stream matching the incoming packet by SSRC or dest addr */
+static struct aes67_rtp_stream *find_sink_stream(struct aes67_rtp_engine *engine,
+                                                  uint32_t ssrc,
+                                                  const struct sockaddr_in *src_addr)
+{
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        struct aes67_rtp_stream *s = engine->streams[i];
+        if (!s || s->direction != AES67_STREAM_SINK || !s->active) {
+            continue;
+        }
+
+        /* Match by SSRC first */
+        if (s->ssrc == ssrc) {
+            return s;
+        }
+
+        /* Match by configured source SSRC */
+        if (s->config.ssrc != 0 && s->config.ssrc == ssrc) {
+            s->ssrc = ssrc;
+            return s;
+        }
+    }
+
+    /* If no SSRC match, find a sink that has SSRC=0 (auto-detect mode) */
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        struct aes67_rtp_stream *s = engine->streams[i];
+        if (!s || s->direction != AES67_STREAM_SINK || !s->active) {
+            continue;
+        }
+        if (s->config.ssrc == 0 && s->first_packet) {
+            /* Lock onto this SSRC */
+            s->ssrc = ssrc;
+            s->first_packet = false;
+            ESP_LOGI(TAG, "Sink stream locked to SSRC 0x%08lx",
+                     (unsigned long)ssrc);
+            return s;
+        }
+    }
+
+    return NULL;
+}
+
+/* --- Raw lwIP UDP callback (runs in lwIP task context) --- */
+
+/* Called directly by lwIP when a UDP packet arrives on port 5004.
+ * No socket layer, no message queue, no context switch.
+ * Processes RTP packets with ~10us overhead instead of ~412us. */
+static void raw_udp_recv_cb(void *arg, struct udp_pcb *pcb,
+                             struct pbuf *p, const ip_addr_t *addr, u16_t port)
+{
+    struct aes67_rtp_engine *engine = (struct aes67_rtp_engine *)arg;
+
+    static uint32_t raw_cb_count = 0;
+    static uint32_t raw_cb_processed = 0;
+    raw_cb_count++;
+
+    if (!p || p->tot_len < AES67_RTP_HEADER_SIZE) {
+        if (p) pbuf_free(p);
+        return;
+    }
+
+    /* Copy pbuf chain to contiguous buffer */
+    uint8_t pkt_buf[2048];
+    if (p->tot_len > sizeof(pkt_buf)) {
+        pbuf_free(p);
+        return;
+    }
+    pbuf_copy_partial(p, pkt_buf, p->tot_len, 0);
+    uint16_t recv_len = p->tot_len;
+    pbuf_free(p);
+
+    /* Parse RTP header */
+    aes67_rtp_header_t hdr;
+    if (!rtp_parse_header(pkt_buf, recv_len, &hdr)) {
+        return;
+    }
+
+    /* Skip own TX packets */
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        struct aes67_rtp_stream *s = engine->streams[i];
+        if (s && s->direction == AES67_STREAM_SOURCE && s->ssrc == hdr.ssrc) {
+            return;
+        }
+    }
+
+    /* Find matching sink stream */
+    struct aes67_rtp_stream *stream = find_sink_stream(engine, hdr.ssrc, NULL);
+    if (!stream) return;
+
+    /* Validate payload type */
+    if (hdr.pt != stream->config.payload_type) return;
+
+    /* Track sequence */
+    uint16_t expected_seq = stream->status.last_seq + 1;
+    if (stream->status.packets_received > 0 && hdr.seq != expected_seq) {
+        int16_t diff = (int16_t)(hdr.seq - expected_seq);
+        if (diff > 0) stream->status.packets_lost += diff;
+        stream->status.seq_errors++;
+    }
+    stream->status.last_seq = hdr.seq;
+    stream->status.packets_received++;
+
+    /* Convert from network format to int32 */
+    const uint8_t *payload = pkt_buf + AES67_RTP_HEADER_SIZE;
+    uint32_t payload_len = recv_len - AES67_RTP_HEADER_SIZE;
+    uint8_t channels = stream->config.channels;
+    uint8_t wl = stream->config.word_length;
+    uint32_t frame_size_bytes = channels * wl;
+    if (frame_size_bytes == 0 || payload_len < frame_size_bytes) return;
+    uint32_t frames = payload_len / frame_size_bytes;
+    if (frames > AES67_MAX_TIC_FRAMES) frames = AES67_MAX_TIC_FRAMES;
+
+    int32_t *sample_buf = engine->raw_sample_buf;
+    stream->convert_from_net(payload, sample_buf, frames, channels);
+
+    /* Write converted int32 samples to stream buffer for playback */
+    if (engine->audio_stream_buf) {
+        xStreamBufferSend(engine->audio_stream_buf, sample_buf,
+                           frames * channels * sizeof(int32_t), 0);
+    }
+
+    raw_cb_processed++;
+
+    /* Log first packet and periodic stats */
+    if (stream->status.packets_received == 1) {
+        const char *codec_str = (wl == 2) ? "L16" : (wl == 3) ? "L24" : "L32";
+        ESP_LOGI(TAG, "RAW RX: %s/%luHz/%uch, %u frames",
+                 codec_str, (unsigned long)stream->config.sample_rate,
+                 channels, frames);
+    }
+    if ((raw_cb_count % 5000) == 0) {
+        ESP_LOGI(TAG, "RAW: cb=%lu proc=%lu lost=%lu",
+                 (unsigned long)raw_cb_count,
+                 (unsigned long)raw_cb_processed,
+                 (unsigned long)stream->status.packets_lost);
+    }
+}
+
+/* --- RX task --- */
+
+static void rx_task_func(void *arg)
+{
+    struct aes67_rtp_engine *engine = (struct aes67_rtp_engine *)arg;
+    uint8_t *rx_buf = heap_caps_malloc(AES67_RX_BUF_SIZE,
+                                       MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
+    if (!rx_buf) {
+        ESP_LOGE(TAG, "Failed to allocate RX buffer");
+        vTaskDelete(NULL);
+        return;
+    }
+
+    /* Temporary buffer for converted samples. 192 frames covers 4ms at 48kHz,
+     * which is the practical AES67 maximum. */
+    int32_t *sample_buf = heap_caps_malloc(
+        CONFIG_AES67_MAX_CHANNELS_PER_STREAM * 192 * sizeof(int32_t),
+        MALLOC_CAP_INTERNAL);
+    if (!sample_buf) {
+        ESP_LOGE(TAG, "Failed to allocate sample conversion buffer");
+        heap_caps_free(rx_buf);
+        vTaskDelete(NULL);
+        return;
+    }
+
+    ESP_LOGI(TAG, "RX task started on port %u", engine->net_config.rtp_port);
+
+    /* Timing stats and packet arrival watchdog */
+    static int64_t t_recv_total = 0, t_proc_total = 0;
+    static uint32_t t_count = 0;
+    static int64_t last_pkt_time_us = 0;
+    static int64_t last_stall_warn_us = 0;
+
+    while (engine->running) {
+        struct sockaddr_in src_addr;
+        socklen_t addr_len = sizeof(src_addr);
+
+        /* First recv blocks until a packet arrives.
+         * Subsequent recvs use MSG_DONTWAIT to drain the socket buffer
+         * without blocking, reducing the per-packet lwIP overhead. */
+        static int drain_pass = 0;
+        int flags = drain_pass ? MSG_DONTWAIT : 0;
+
+        int64_t t0 = esp_timer_get_time();
+        int recv_len = recvfrom(engine->rx_sock, rx_buf, AES67_RX_BUF_SIZE, flags,
+                                (struct sockaddr *)&src_addr, &addr_len);
+        int64_t t1 = esp_timer_get_time();
+        if (recv_len < 0) {
+            if (errno == EAGAIN || errno == EWOULDBLOCK) {
+                drain_pass = 0;
+                /* Stall watchdog disabled: Ethernet hook handles RTP
+                 * reception at L2, so the socket RX path sees nothing. */
+                continue;
+            }
+            if (!engine->running) {
+                break;
+            }
+            ESP_LOGW(TAG, "recvfrom error: %d (%s)", errno, strerror(errno));
+            drain_pass = 0;
+            continue;
+        }
+        drain_pass = 1;
+        last_pkt_time_us = esp_timer_get_time();
+
+        if (recv_len < AES67_RTP_HEADER_SIZE) {
+            continue;
+        }
+
+        /* Parse RTP header */
+        aes67_rtp_header_t hdr;
+        if (!rtp_parse_header(rx_buf, recv_len, &hdr)) {
+            continue;
+        }
+
+        /* Skip our own TX packets (same port, different multicast group).
+         * Check all source streams for SSRC match. */
+        bool is_own = false;
+        for (int i = 0; i < AES67_MAX_STREAMS && !is_own; i++) {
+            struct aes67_rtp_stream *s = engine->streams[i];
+            if (s && s->direction == AES67_STREAM_SOURCE && s->ssrc == hdr.ssrc) {
+                is_own = true;
+            }
+        }
+        if (is_own) continue;
+
+        /* Find the matching sink stream */
+        struct aes67_rtp_stream *stream = find_sink_stream(engine, hdr.ssrc,
+                                                            &src_addr);
+        if (!stream) {
+            continue;
+        }
+
+        /* Validate payload type */
+        if (hdr.pt != stream->config.payload_type) {
+            stream->status.status_flags |= AES67_RTP_STATUS_PT_ERROR;
+            continue;
+        }
+
+        /* Check sequence continuity */
+        uint16_t expected_seq = stream->status.last_seq + 1;
+        if (stream->status.packets_received > 0 && hdr.seq != expected_seq) {
+            int16_t diff = (int16_t)(hdr.seq - expected_seq);
+            if (diff > 0) {
+                stream->status.packets_lost += diff;
+            }
+            stream->status.seq_errors++;
+            stream->status.status_flags |= AES67_RTP_STATUS_SEQ_ERROR;
+        }
+
+        stream->status.last_seq = hdr.seq;
+        stream->status.last_rtp_timestamp = hdr.timestamp;
+        stream->status.status_flags |= AES67_RTP_STATUS_RECEIVING;
+
+        /* Measure inter-packet arrival jitter (RFC 3550 algorithm).
+         * Uses esp_timer for microsecond precision. */
+        int64_t now_us = esp_timer_get_time();
+        if (stream->last_rx_time_us > 0) {
+            int64_t expected_us = (int64_t)stream->samples_per_packet
+                                  * 1000000LL / stream->config.sample_rate;
+            int64_t actual_us = now_us - stream->last_rx_time_us;
+            int32_t deviation = (int32_t)(actual_us - expected_us);
+            if (deviation < 0) deviation = -deviation;
+            /* Exponential moving average: jitter += (deviation - jitter) / 16 */
+            stream->jitter_us += (deviation - stream->jitter_us) / 16;
+            stream->status.jitter_us = stream->jitter_us;
+        }
+        stream->last_rx_time_us = now_us;
+
+        /* Convert from network byte order to native samples.
+         * Use actual payload size to determine frame count, not the
+         * SDP-declared samples_per_packet (which may differ). */
+        const uint8_t *payload = rx_buf + AES67_RTP_HEADER_SIZE;
+        uint32_t payload_len = recv_len - AES67_RTP_HEADER_SIZE;
+        uint8_t channels = stream->config.channels;
+        uint8_t wl = stream->config.word_length;
+
+        /* Compute actual frames from payload length */
+        uint32_t frame_size_bytes = channels * wl;
+        if (frame_size_bytes == 0 || payload_len < frame_size_bytes) {
+            continue;
+        }
+        uint32_t frames = payload_len / frame_size_bytes;
+
+        /* Limit to conversion buffer capacity (192 frames max) */
+        uint32_t max_frames = CONFIG_AES67_MAX_CHANNELS_PER_STREAM *
+                              192 / channels;
+        if (frames > max_frames) frames = max_frames;
+
+        stream->convert_from_net(payload, sample_buf, frames, channels);
+
+        /* Route converted int32 samples to stream buffer or jitter buffer */
+        if (engine->audio_stream_buf) {
+            xStreamBufferSend(engine->audio_stream_buf, sample_buf,
+                               frames * channels * sizeof(int32_t), 0);
+        } else {
+            uint32_t data_bytes = frames * channels * sizeof(int32_t);
+            ringbuf_write(&stream->ring,
+                          (const uint8_t *)sample_buf, data_bytes);
+        }
+
+        stream->status.packets_received++;
+
+        int64_t t2 = esp_timer_get_time();
+        t_recv_total += (t1 - t0);
+        t_proc_total += (t2 - t1);
+        t_count++;
+
+        if ((stream->status.packets_received % 10000) == 0) {
+            ESP_LOGI(TAG, "RX: %luk pkts, %lu lost, j=%ldus | "
+                     "recv=%lldus proc=%lldus cycle=%lldus (avg over %lu)",
+                     (unsigned long)(stream->status.packets_received / 1000),
+                     (unsigned long)stream->status.packets_lost,
+                     (long)stream->jitter_us,
+                     t_count > 0 ? (long long)(t_recv_total / t_count) : 0,
+                     t_count > 0 ? (long long)(t_proc_total / t_count) : 0,
+                     t_count > 0 ? (long long)((t_recv_total + t_proc_total) / t_count) : 0,
+                     (unsigned long)t_count);
+        }
+
+        /* Log first packet only */
+        if (stream->status.packets_received == 1) {
+            const char *codec_str = (wl == 2) ? "L16" : (wl == 3) ? "L24" : (wl == 4) ? "L32" : "???";
+            ESP_LOGI(TAG, "RX: %s/%luHz/%uch, %lu bytes, %u frames",
+                     codec_str, (unsigned long)stream->config.sample_rate,
+                     channels, (unsigned long)payload_len, frames);
+        }
+    }
+
+    heap_caps_free(sample_buf);
+    heap_caps_free(rx_buf);
+    ESP_LOGI(TAG, "RX task stopped");
+    vTaskDelete(NULL);
+}
+
+/* --- Public API --- */
+
+esp_err_t aes67_rtp_engine_init(const aes67_net_config_t *net_config,
+                                bool use_psram,
+                                aes67_rtp_engine_handle_t *handle)
+{
+    if (!net_config || !handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = calloc(1, sizeof(*engine));
+    if (!engine) {
+        ESP_LOGE(TAG, "Failed to allocate engine");
+        return ESP_ERR_NO_MEM;
+    }
+
+    memcpy(&engine->net_config, net_config, sizeof(aes67_net_config_t));
+    engine->use_psram = use_psram;
+    engine->running = false;
+    engine->rx_sock = -1;
+    engine->rx_task = NULL;
+    engine->stream_count = 0;
+
+    /* Stream buffer: sized for practical worst-case, not theoretical max.
+     * AES67 mandates max 8 channels, max 4ms packet time at 48kHz = 192 frames.
+     * 192 * 8ch * 4 bytes = 6144 per packet. Buffer 8 packets = 49152. */
+    uint32_t practical_max_pkt = 192 * CONFIG_AES67_MAX_CHANNELS_PER_STREAM * sizeof(int32_t);
+    uint32_t sbuf_size = practical_max_pkt * 8;
+    engine->audio_stream_buf = xStreamBufferCreate(sbuf_size, practical_max_pkt);
+
+    /* Raw lwIP UDP PCB disabled when Ethernet hook is active.
+     * The hook intercepts RTP at L2 before lwIP, so the raw PCB would
+     * either see nothing (packets freed by hook) or duplicate-process
+     * packets that slip through, wasting CPU and memory.
+     * The raw PCB is only useful as fallback when the hook is not installed. */
+#if 0  /* Disabled: Ethernet hook handles RTP reception */
+    engine->raw_pcb = udp_new();
+    if (engine->raw_pcb) {
+        err_t err = udp_bind(engine->raw_pcb, IP_ADDR_ANY, net_config->rtp_port);
+        if (err == ERR_OK) {
+            udp_recv(engine->raw_pcb, raw_udp_recv_cb, engine);
+            ESP_LOGI(TAG, "Raw UDP PCB bound to port %u", net_config->rtp_port);
+        } else {
+            udp_remove(engine->raw_pcb);
+            engine->raw_pcb = NULL;
+        }
+    }
+    engine->raw_sample_buf = heap_caps_malloc(
+        CONFIG_AES67_MAX_CHANNELS_PER_STREAM * 192 * sizeof(int32_t),
+        MALLOC_CAP_INTERNAL);
+#else
+    engine->raw_pcb = NULL;
+    engine->raw_sample_buf = NULL;
+    ESP_LOGI(TAG, "Raw UDP PCB disabled (Ethernet hook handles RTP)");
+#endif
+    if (!engine->audio_stream_buf) {
+        ESP_LOGE(TAG, "Failed to create audio stream buffer");
+    }
+
+    /* Create the RX socket only if raw PCB is not active.
+     * Raw PCB already binds to the port, so socket bind would fail. */
+    if (!engine->raw_pcb) {
+        engine->rx_sock = aes67_net_create_udp_socket(net_config->rtp_port, true);
+        if (engine->rx_sock < 0) {
+            ESP_LOGE(TAG, "Failed to create RX socket on port %u",
+                     net_config->rtp_port);
+            free(engine);
+            return ESP_FAIL;
+        }
+    } else {
+        /* Still need a socket for TX and multicast joins */
+        engine->rx_sock = aes67_net_create_udp_socket(0, true);  /* Ephemeral port */
+    }
+
+    /* Set socket receive timeout so the RX task can check the running flag */
+    struct timeval tv = { .tv_sec = 0, .tv_usec = 100000 }; /* 100ms */
+    setsockopt(engine->rx_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+
+    /* RX buffer: 32KB holds ~28 packets at 1152 bytes each = ~112ms */
+    aes67_net_set_buffers(engine->rx_sock, 0, 32768);
+
+    *handle = engine;
+    ESP_LOGI(TAG, "Engine initialized, RX socket on port %u",
+             net_config->rtp_port);
+    return ESP_OK;
+}
+
+StreamBufferHandle_t aes67_rtp_engine_get_stream_buf(aes67_rtp_engine_handle_t handle)
+{
+    return handle ? handle->audio_stream_buf : NULL;
+}
+
+void aes67_rtp_engine_set_notify_task(aes67_rtp_engine_handle_t handle,
+                                      TaskHandle_t task)
+{
+    if (handle) {
+        handle->playback_notify_task = task;
+    }
+}
+
+void aes67_rtp_engine_set_playback(aes67_rtp_engine_handle_t handle,
+                                   void *audio_handle)
+{
+    if (handle) {
+        handle->audio_handle = audio_handle;
+        handle->i2s_playback_enabled = (audio_handle != NULL);
+        ESP_LOGI(TAG, "I2S playback from RX task %s",
+                 handle->i2s_playback_enabled ? "enabled" : "disabled");
+    }
+}
+
+esp_err_t aes67_rtp_engine_start(aes67_rtp_engine_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = handle;
+    if (engine->running) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    engine->running = true;
+
+    /* Start the RX task */
+    /* RX task priority must be BELOW lwIP (22) so lwIP can process
+     * incoming Ethernet frames. RX task runs when lwIP delivers
+     * data to the socket, not the other way around. */
+    BaseType_t ret = xTaskCreatePinnedToCore(
+        rx_task_func,
+        "aes67_rx",
+        AES67_RX_TASK_STACK,
+        engine,
+        17,  /* Below lwIP (23) */
+        &engine->rx_task,
+        0   /* Core 0: all audio/network on same core for cache coherency */
+    );
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "Failed to create RX task");
+        engine->running = false;
+        return ESP_FAIL;
+    }
+
+    ESP_LOGI(TAG, "Engine started");
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_engine_stop(aes67_rtp_engine_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = handle;
+    if (!engine->running) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    engine->running = false;
+
+    /* The RX task will exit on the next recvfrom timeout */
+    if (engine->rx_task) {
+        /* Give the task time to notice the flag and exit */
+        vTaskDelay(pdMS_TO_TICKS(200));
+        engine->rx_task = NULL;
+    }
+
+    ESP_LOGI(TAG, "Engine stopped");
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_engine_destroy(aes67_rtp_engine_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = handle;
+
+    /* Stop if still running */
+    if (engine->running) {
+        aes67_rtp_engine_stop(engine);
+    }
+
+    /* Remove all streams */
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        if (engine->streams[i]) {
+            aes67_rtp_stream_remove(engine, engine->streams[i]);
+        }
+    }
+
+    /* Close RX socket */
+    if (engine->rx_sock >= 0) {
+        close(engine->rx_sock);
+        engine->rx_sock = -1;
+    }
+
+    free(engine);
+    ESP_LOGI(TAG, "Engine destroyed");
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_stream_add(aes67_rtp_engine_handle_t engine_handle,
+                               const aes67_rtp_stream_config_t *config,
+                               aes67_rtp_stream_handle_t *stream)
+{
+    if (!engine_handle || !config || !stream) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = engine_handle;
+
+    /* Find a free slot */
+    int slot = -1;
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        if (!engine->streams[i]) {
+            slot = i;
+            break;
+        }
+    }
+    if (slot < 0) {
+        ESP_LOGE(TAG, "Maximum number of streams reached (%d)", AES67_MAX_STREAMS);
+        return ESP_ERR_NO_MEM;
+    }
+
+    struct aes67_rtp_stream *s = calloc(1, sizeof(*s));
+    if (!s) {
+        return ESP_ERR_NO_MEM;
+    }
+
+    memcpy(&s->config, config, sizeof(aes67_rtp_stream_config_t));
+    s->direction = config->direction;
+    s->ssrc = config->ssrc;
+    s->seq_num = 0;
+    s->tx_sock = -1;
+    s->active = false;
+    s->first_packet = true;
+    memset(&s->status, 0, sizeof(s->status));
+
+    /* Compute all derived stream parameters from base config */
+    aes67_stream_params_compute(&s->params,
+                                config->sample_rate,
+                                config->channels,
+                                config->word_length,
+                                config->packet_time_us);
+    s->samples_per_packet = s->params.samples_per_packet;
+    s->packet_payload_size = s->params.packet_payload_size;
+
+    /* Resolve codec conversion function pointers (MTConvert pattern).
+     * This avoids branching on word_length in the per-packet hot path. */
+    s->convert_to_net = aes67_get_to_net_fn(config->word_length);
+    s->convert_from_net = aes67_get_from_net_fn(config->word_length);
+
+    ESP_LOGD(TAG, "Stream: spp=%lu, payload=%lu, native=%lu bytes",
+             (unsigned long)s->params.samples_per_packet,
+             (unsigned long)s->params.packet_payload_size,
+             (unsigned long)s->params.packet_native_size);
+
+    /* Allocate ring and scratch buffers */
+    esp_err_t err = stream_alloc_buffers(s, engine->use_psram);
+    if (err != ESP_OK) {
+        free(s);
+        return err;
+    }
+
+    /* Set up destination address */
+    memset(&s->dest_addr, 0, sizeof(s->dest_addr));
+    s->dest_addr.sin_family = AF_INET;
+    s->dest_addr.sin_port = htons(config->dest_port);
+    s->dest_addr.sin_addr.s_addr = config->dest_ip;
+
+    /* Source streams: create a TX socket */
+    if (s->direction == AES67_STREAM_SOURCE) {
+        s->tx_sock = aes67_net_create_udp_socket(config->src_port, true);
+        if (s->tx_sock < 0) {
+            ESP_LOGE(TAG, "Failed to create TX socket on port %u",
+                     config->src_port);
+            stream_free_buffers(s);
+            free(s);
+            return ESP_FAIL;
+        }
+
+        /* Set QoS and TTL on the TX socket */
+        aes67_net_set_dscp(s->tx_sock, config->dscp);
+        aes67_net_set_multicast_ttl(s->tx_sock, config->ttl);
+    }
+
+    /* Sink streams: join multicast group */
+    if (s->direction == AES67_STREAM_SINK && aes67_net_is_multicast(config->dest_ip)) {
+        char mcast_str[16];
+        aes67_net_u32_to_ip(config->dest_ip, mcast_str, sizeof(mcast_str));
+        /* Join via socket (for legacy RX task) */
+        err = aes67_net_join_multicast(engine->rx_sock, mcast_str, NULL);
+        /* Also join via lwIP IGMP for the raw PCB path */
+        if (engine->raw_pcb) {
+            ip4_addr_t mcast_ip;
+            mcast_ip.addr = config->dest_ip;
+            igmp_joingroup(IP4_ADDR_ANY4, &mcast_ip);
+        }
+        if (err != ESP_OK) {
+            ESP_LOGW(TAG, "Failed to join multicast group %s", mcast_str);
+        }
+    }
+
+    s->active = true;
+    engine->streams[slot] = s;
+    engine->stream_count++;
+    *stream = s;
+
+    char ip_str[16];
+    aes67_net_u32_to_ip(config->dest_ip, ip_str, sizeof(ip_str));
+    ESP_LOGI(TAG, "Added %s stream [%d]: %s:%u, %u ch, %u Hz, %u-bit, SSRC=0x%08lx",
+             s->direction == AES67_STREAM_SOURCE ? "source" : "sink",
+             slot, ip_str, config->dest_port,
+             config->channels, config->sample_rate,
+             config->word_length * 8, (unsigned long)config->ssrc);
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_stream_remove(aes67_rtp_engine_handle_t engine_handle,
+                                  aes67_rtp_stream_handle_t stream)
+{
+    if (!engine_handle || !stream) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = engine_handle;
+    struct aes67_rtp_stream *s = stream;
+
+    /* Find and clear the slot */
+    int slot = -1;
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        if (engine->streams[i] == s) {
+            slot = i;
+            engine->streams[i] = NULL;
+            break;
+        }
+    }
+    if (slot < 0) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    s->active = false;
+
+    /* Leave multicast group for sink streams */
+    if (s->direction == AES67_STREAM_SINK &&
+        aes67_net_is_multicast(s->config.dest_ip)) {
+        char mcast_str[16];
+        aes67_net_u32_to_ip(s->config.dest_ip, mcast_str, sizeof(mcast_str));
+        aes67_net_leave_multicast(engine->rx_sock, mcast_str, NULL);
+    }
+
+    /* Close TX socket for source streams */
+    if (s->tx_sock >= 0) {
+        close(s->tx_sock);
+        s->tx_sock = -1;
+    }
+
+    stream_free_buffers(s);
+    engine->stream_count--;
+
+    ESP_LOGI(TAG, "Removed stream [%d]", slot);
+    free(s);
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_stream_get_status(aes67_rtp_stream_handle_t stream,
+                                      aes67_rtp_stream_status_t *status)
+{
+    if (!stream || !status) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    memcpy(status, &stream->status, sizeof(aes67_rtp_stream_status_t));
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_source_write(aes67_rtp_stream_handle_t stream,
+                                 const void *samples, uint32_t frame_count)
+{
+    if (!stream || !samples) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (stream->direction != AES67_STREAM_SOURCE) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (!stream->active) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    /* Caller provides int32_t samples (left-justified). Store 4 bytes per
+     * sample in the ring buffer regardless of the wire word_length. */
+    uint32_t byte_count = frame_count * stream->config.channels * sizeof(int32_t);
+    uint32_t written = ringbuf_write(&stream->ring, (const uint8_t *)samples,
+                                     byte_count);
+    if (written < byte_count) {
+        stream->status.status_flags |= AES67_RTP_STATUS_OVERFLOW;
+        return ESP_ERR_NO_MEM;
+    }
+
+    return ESP_OK;
+}
+
+esp_err_t aes67_rtp_sink_read(aes67_rtp_stream_handle_t stream,
+                              void *samples, uint32_t frame_count)
+{
+    if (!stream || !samples) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (stream->direction != AES67_STREAM_SINK) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    /* Ring buffer stores int32_t samples (4 bytes each) */
+    uint32_t byte_count = frame_count * stream->config.channels * sizeof(int32_t);
+
+    /* If stream is muted, return silence */
+    if (stream->status.status_flags & AES67_RTP_STATUS_MUTED) {
+        memset(samples, 0, byte_count);
+        return ESP_OK;
+    }
+
+    uint32_t avail = ringbuf_available(&stream->ring);
+    if (avail < byte_count) {
+        memset(samples, 0, byte_count);
+        stream->status.status_flags |= AES67_RTP_STATUS_UNDERFLOW;
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    uint32_t read_bytes = ringbuf_read(&stream->ring, (uint8_t *)samples,
+                                       byte_count);
+    if (read_bytes < byte_count) {
+        /* Partial read, zero remainder */
+        memset((uint8_t *)samples + read_bytes, 0, byte_count - read_bytes);
+        stream->status.status_flags |= AES67_RTP_STATUS_UNDERFLOW;
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    /* Clear underflow flag on successful read */
+    stream->status.status_flags &= ~AES67_RTP_STATUS_UNDERFLOW;
+    return ESP_OK;
+}
+
+uint32_t aes67_rtp_sink_available(aes67_rtp_stream_handle_t stream)
+{
+    if (!stream || stream->direction != AES67_STREAM_SINK) return 0;
+    uint32_t bytes = ringbuf_available(&stream->ring);
+    uint32_t frame_bytes = stream->config.channels * sizeof(int32_t);
+    return frame_bytes > 0 ? bytes / frame_bytes : 0;
+}
+
+esp_err_t aes67_rtp_engine_process_tx(aes67_rtp_engine_handle_t handle,
+                                      uint32_t rtp_timestamp)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_rtp_engine *engine = handle;
+    if (!engine->running) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    for (int i = 0; i < AES67_MAX_STREAMS; i++) {
+        struct aes67_rtp_stream *s = engine->streams[i];
+        if (!s || s->direction != AES67_STREAM_SOURCE || !s->active) {
+            continue;
+        }
+
+        /* Initialize stream timestamp from PTP on first TX, then
+         * increment sequentially. The receiver validates that each
+         * packet's timestamp = prev_timestamp + prev_sample_count.
+         * (RAVENNA RTP_audio_stream.c line 647) */
+        if (!s->timestamp_initialized) {
+            s->rtp_timestamp = rtp_timestamp;
+            s->timestamp_initialized = true;
+        }
+
+        /* Ring buffer stores int32_t samples (4 bytes each, native endian).
+         * Read them into a temp area, then convert to packed network order. */
+        uint32_t samples_total = s->samples_per_packet * s->config.channels;
+        uint32_t needed_bytes = samples_total * sizeof(int32_t);
+
+        /* Check if we have enough data in the ring buffer */
+        uint32_t avail = ringbuf_available(&s->ring);
+        if (avail < needed_bytes) {
+            s->status.status_flags |= AES67_RTP_STATUS_UNDERFLOW;
+            continue;
+        }
+
+        uint8_t *pkt = s->tx_packet_buf;
+        uint8_t *payload = pkt + AES67_RTP_HEADER_SIZE;
+
+        /* The payload area is sized for packed wire format. We need a
+         * temporary buffer for the int32_t samples read from the ring.
+         * Use the payload area (which is at least as large) plus some
+         * extra stack space if needed. For safety, use a heap buffer
+         * that the stream already has - the payload area is
+         * samples_per_packet * channels * word_length bytes, while we
+         * need samples_per_packet * channels * 4 bytes. When word_length < 4,
+         * the payload area is too small. Use a small stack buffer. */
+        int32_t sample_buf[CONFIG_AES67_MAX_CHANNELS_PER_STREAM * 48];
+        uint32_t frames = s->samples_per_packet;
+        uint8_t channels = s->config.channels;
+        uint8_t wl = s->config.word_length;
+
+        /* Process in chunks if samples_per_packet exceeds our stack buffer */
+        uint32_t chunk_frames = sizeof(sample_buf) / (channels * sizeof(int32_t));
+        uint32_t frames_done = 0;
+        uint8_t *payload_ptr = payload;
+
+        while (frames_done < frames) {
+            uint32_t batch = frames - frames_done;
+            if (batch > chunk_frames) batch = chunk_frames;
+
+            uint32_t batch_bytes = batch * channels * sizeof(int32_t);
+            uint32_t read_bytes = ringbuf_read(&s->ring,
+                                               (uint8_t *)sample_buf,
+                                               batch_bytes);
+            if (read_bytes < batch_bytes) {
+                s->status.status_flags |= AES67_RTP_STATUS_UNDERFLOW;
+                break;
+            }
+
+            /* Convert int32_t native samples to packed big-endian wire format */
+            s->convert_to_net(sample_buf, payload_ptr, batch, channels);
+
+            payload_ptr += batch * channels * wl;
+            frames_done += batch;
+        }
+
+        if (frames_done < frames) {
+            continue;
+        }
+
+        rtp_build_header(pkt, s->config.payload_type, s->seq_num,
+                         s->rtp_timestamp, s->ssrc);
+        s->seq_num++;
+        s->rtp_timestamp += s->samples_per_packet;
+
+        /* Send the packet */
+        uint32_t pkt_size = AES67_RTP_HEADER_SIZE + s->packet_payload_size;
+        ssize_t sent = sendto(s->tx_sock, pkt, pkt_size, 0,
+                              (struct sockaddr *)&s->dest_addr,
+                              sizeof(s->dest_addr));
+        if (sent < 0) {
+            ESP_LOGW(TAG, "sendto failed for stream [%d]: %d (%s)",
+                     i, errno, strerror(errno));
+        } else {
+            s->status.packets_sent++;
+            s->status.last_rtp_timestamp = rtp_timestamp;
+            s->status.last_seq = s->seq_num - 1;
+            /* Clear underflow on successful send */
+            s->status.status_flags &= ~AES67_RTP_STATUS_UNDERFLOW;
+        }
+    }
+
+    return ESP_OK;
+}

+ 599 - 0
components/aes67/src/aes67_sap.c

@@ -0,0 +1,599 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * SAP (Session Announcement Protocol) - RFC 2974
+ *
+ * Handles periodic multicast announcement and discovery of AES67
+ * audio sessions via SDP payloads over the SAP multicast address.
+ */
+
+#include "aes67_sap.h"
+#include "aes67_sdp.h"
+#include "aes67_net.h"
+
+#include <string.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <unistd.h>
+#include <errno.h>
+
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+static const char *TAG = "aes67_sap";
+
+/* SAP protocol constants */
+#define SAP_HEADER_SIZE      8
+#define SAP_CONTENT_TYPE     "application/sdp"
+#define SAP_CONTENT_TYPE_LEN 16  /* includes null terminator */
+#define SAP_ANNOUNCE_FLAG    0x20
+#define SAP_DELETE_FLAG      0x24
+#define SAP_TX_INTERVAL_MS   2000    /* Announce every 2s for fast discovery */
+#define SAP_TIMEOUT_MS       10000   /* Expire remote sources after 10s silence */
+#define SAP_RX_BUF_SIZE      1500
+#define SAP_MAX_LOCAL         8
+#define SAP_MAX_REMOTE        16
+
+#define SAP_RX_TASK_STACK     12288  /* Needs room for callback -> add_source -> SDP log */
+#define SAP_TX_TASK_STACK     4096
+#define SAP_RX_TASK_PRIO      5   /* Background - discovery */
+#define SAP_TX_TASK_PRIO      4   /* Background - periodic announce */
+
+/* Locally announced source */
+typedef struct {
+    bool active;
+    uint16_t msg_id;
+    uint32_t origin_ip;
+    char sdp[AES67_SAP_MAX_SDP_LEN];
+} sap_local_source_t;
+
+/* Full SAP context */
+struct aes67_sap_ctx {
+    int sock;
+    TaskHandle_t rx_task;
+    TaskHandle_t tx_task;
+    bool running;
+
+    aes67_sap_event_cb_t event_cb;
+    void *user_data;
+
+    sap_local_source_t local[SAP_MAX_LOCAL];
+    int local_count;
+
+    aes67_sap_remote_source_t remote[SAP_MAX_REMOTE];
+    int remote_count;
+};
+
+/* Build a SAP packet (announce or delete) into buf.
+ * Returns total packet length, or -1 on error. */
+static int build_sap_packet(uint8_t *buf, size_t buf_len,
+                            uint8_t flags, uint16_t msg_id,
+                            uint32_t origin_ip, const char *sdp)
+{
+    size_t sdp_len = strlen(sdp);
+    size_t total = SAP_HEADER_SIZE + SAP_CONTENT_TYPE_LEN + sdp_len;
+    if (total > buf_len) {
+        return -1;
+    }
+
+    /* SAP header per RFC 2974 */
+    buf[0] = flags;
+    buf[1] = 0;  /* auth_len = 0 */
+    buf[2] = (uint8_t)(msg_id >> 8);
+    buf[3] = (uint8_t)(msg_id & 0xFF);
+
+    /* Originating source IP. The origin_ip value from lwIP is already
+     * in network byte order, so copy the 4 bytes directly. */
+    memcpy(buf + 4, &origin_ip, 4);
+
+    /* Content type string with null terminator */
+    memcpy(buf + SAP_HEADER_SIZE, SAP_CONTENT_TYPE, SAP_CONTENT_TYPE_LEN);
+
+    /* SDP payload */
+    memcpy(buf + SAP_HEADER_SIZE + SAP_CONTENT_TYPE_LEN, sdp, sdp_len);
+
+    return (int)total;
+}
+
+/* Send a SAP packet to the multicast group */
+static esp_err_t send_sap_packet(int sock, const uint8_t *pkt, int pkt_len)
+{
+    struct sockaddr_in dest = {
+        .sin_family = AF_INET,
+        .sin_port = htons(AES67_SAP_PORT),
+    };
+    inet_aton(AES67_SAP_MCAST_ADDR, &dest.sin_addr);
+
+    int sent = sendto(sock, pkt, pkt_len, 0,
+                      (struct sockaddr *)&dest, sizeof(dest));
+    if (sent < 0) {
+        ESP_LOGE(TAG, "SAP sendto %s:%d failed: errno %d",
+                 AES67_SAP_MCAST_ADDR, AES67_SAP_PORT, errno);
+        return ESP_FAIL;
+    }
+    ESP_LOGD(TAG, "SAP sent %d bytes to %s:%d",
+             sent, AES67_SAP_MCAST_ADDR, AES67_SAP_PORT);
+    return ESP_OK;
+}
+
+/* Find a remote source by origin_ip + msg_id. Returns index or -1. */
+static int find_remote(struct aes67_sap_ctx *ctx, uint32_t origin_ip,
+                       uint16_t msg_id)
+{
+    for (int i = 0; i < ctx->remote_count; i++) {
+        if (ctx->remote[i].origin_ip == origin_ip &&
+            ctx->remote[i].msg_id == msg_id) {
+            return i;
+        }
+    }
+    return -1;
+}
+
+/* Remove a remote source by index, compacting the array */
+static void remove_remote(struct aes67_sap_ctx *ctx, int idx)
+{
+    if (idx < 0 || idx >= ctx->remote_count) return;
+
+    if (idx < ctx->remote_count - 1) {
+        memmove(&ctx->remote[idx], &ctx->remote[idx + 1],
+                sizeof(aes67_sap_remote_source_t) * (ctx->remote_count - 1 - idx));
+    }
+    ctx->remote_count--;
+}
+
+/* Extract session name from SDP text for the remote source name field */
+static void extract_session_name(const char *sdp, char *name, size_t name_len)
+{
+    const char *s_line = strstr(sdp, "s=");
+    if (!s_line) {
+        strncpy(name, "(unknown)", name_len - 1);
+        name[name_len - 1] = '\0';
+        return;
+    }
+
+    s_line += 2;
+    const char *end = s_line;
+    while (*end && *end != '\r' && *end != '\n') {
+        end++;
+    }
+
+    size_t len = (size_t)(end - s_line);
+    if (len >= name_len) len = name_len - 1;
+    memcpy(name, s_line, len);
+    name[len] = '\0';
+}
+
+/* Handle a received SAP packet */
+static void handle_sap_rx(struct aes67_sap_ctx *ctx, const uint8_t *data,
+                          int len)
+{
+    /* Minimum: 8 byte header + content type + some SDP */
+    if (len < SAP_HEADER_SIZE + SAP_CONTENT_TYPE_LEN + 4) {
+        return;
+    }
+
+    uint8_t flags = data[0];
+    uint16_t msg_id = ((uint16_t)data[2] << 8) | data[3];
+    uint32_t origin_ip;
+    memcpy(&origin_ip, data + 4, 4); /* Already in network byte order */
+
+    /* Verify content type */
+    if (memcmp(data + SAP_HEADER_SIZE, SAP_CONTENT_TYPE,
+               SAP_CONTENT_TYPE_LEN) != 0) {
+        ESP_LOGD(TAG, "SAP packet with unknown content type, ignoring");
+        return;
+    }
+
+    const char *sdp = (const char *)(data + SAP_HEADER_SIZE + SAP_CONTENT_TYPE_LEN);
+    int sdp_len = len - SAP_HEADER_SIZE - SAP_CONTENT_TYPE_LEN;
+
+    bool is_delete = (flags & 0x04) != 0;
+
+    if (is_delete) {
+        /* Remove this source */
+        int idx = find_remote(ctx, origin_ip, msg_id);
+        if (idx >= 0) {
+            aes67_sap_remote_source_t removed = ctx->remote[idx];
+            remove_remote(ctx, idx);
+            ESP_LOGI(TAG, "Source gone: \"%s\" (%d sources remaining)",
+                     removed.name, ctx->remote_count);
+            if (ctx->event_cb) {
+                ctx->event_cb(false, &removed, ctx->user_data);
+            }
+        }
+    } else {
+        /* Announce: add or update */
+        int idx = find_remote(ctx, origin_ip, msg_id);
+        if (idx >= 0) {
+            /* Update existing entry timestamp */
+            ctx->remote[idx].last_seen = xTaskGetTickCount();
+            /* Update SDP in case it changed */
+            if (sdp_len < (int)sizeof(ctx->remote[idx].sdp)) {
+                memcpy(ctx->remote[idx].sdp, sdp, sdp_len);
+                ctx->remote[idx].sdp[sdp_len] = '\0';
+                extract_session_name(ctx->remote[idx].sdp,
+                                     ctx->remote[idx].name,
+                                     sizeof(ctx->remote[idx].name));
+            }
+        } else {
+            /* New source */
+            if (ctx->remote_count >= SAP_MAX_REMOTE) {
+                ESP_LOGW(TAG, "Remote source table full, ignoring new source");
+                return;
+            }
+
+            aes67_sap_remote_source_t *src = &ctx->remote[ctx->remote_count];
+            memset(src, 0, sizeof(*src));
+            src->origin_ip = origin_ip;
+            src->msg_id = msg_id;
+            src->last_seen = xTaskGetTickCount();
+
+            if (sdp_len < (int)sizeof(src->sdp)) {
+                memcpy(src->sdp, sdp, sdp_len);
+                src->sdp[sdp_len] = '\0';
+            }
+            extract_session_name(src->sdp, src->name, sizeof(src->name));
+
+            ctx->remote_count++;
+
+            /* Parse SDP for additional info to display */
+            char ip_str[16] = {0};
+            struct in_addr addr_tmp = { .s_addr = origin_ip };
+            const char *ip = inet_ntoa(addr_tmp);
+            if (ip) {
+                strncpy(ip_str, ip, sizeof(ip_str) - 1);
+            }
+
+            ESP_LOGI(TAG, "Discovered: \"%s\" from %s (%d sources on network)",
+                     src->name, ip_str, ctx->remote_count);
+            if (ctx->event_cb) {
+                ctx->event_cb(true, src, ctx->user_data);
+            }
+        }
+    }
+}
+
+/* Check for timed-out remote sources (not seen within SAP_TIMEOUT_MS) */
+static void expire_remote_sources(struct aes67_sap_ctx *ctx)
+{
+    uint32_t now = xTaskGetTickCount();
+    uint32_t timeout_ticks = pdMS_TO_TICKS(SAP_TIMEOUT_MS);
+
+    for (int i = ctx->remote_count - 1; i >= 0; i--) {
+        uint32_t elapsed = now - ctx->remote[i].last_seen;
+        if (elapsed >= timeout_ticks) {
+            aes67_sap_remote_source_t expired = ctx->remote[i];
+            remove_remote(ctx, i);
+            ESP_LOGI(TAG, "Source timeout: \"%s\" (no announcement for %ds)",
+                     expired.name, SAP_TIMEOUT_MS / 1000);
+            if (ctx->event_cb) {
+                ctx->event_cb(false, &expired, ctx->user_data);
+            }
+        }
+    }
+}
+
+/* RX task: listens for incoming SAP packets */
+static void sap_rx_task(void *arg)
+{
+    struct aes67_sap_ctx *ctx = (struct aes67_sap_ctx *)arg;
+
+    /* Allocate RX buffer on heap to avoid stack overflow. select() and
+     * the newlib printf internals consume significant stack space. */
+    uint8_t *rx_buf = malloc(SAP_RX_BUF_SIZE);
+    if (!rx_buf) {
+        ESP_LOGE(TAG, "Failed to allocate SAP RX buffer");
+        vTaskDelete(NULL);
+        return;
+    }
+
+    ESP_LOGI(TAG, "SAP RX task started");
+
+    while (ctx->running) {
+        /* Use a timeout so we can periodically check for expired sources */
+        struct timeval tv = {
+            .tv_sec = 5,
+            .tv_usec = 0,
+        };
+        fd_set rfds;
+        FD_ZERO(&rfds);
+        FD_SET(ctx->sock, &rfds);
+
+        int ret = select(ctx->sock + 1, &rfds, NULL, NULL, &tv);
+        if (ret < 0) {
+            if (errno == EINTR) continue;
+            ESP_LOGE(TAG, "SAP select() failed: errno %d", errno);
+            vTaskDelay(pdMS_TO_TICKS(1000));
+            continue;
+        }
+
+        if (ret > 0 && FD_ISSET(ctx->sock, &rfds)) {
+            struct sockaddr_in from;
+            socklen_t from_len = sizeof(from);
+            int len = recvfrom(ctx->sock, rx_buf, SAP_RX_BUF_SIZE, 0,
+                               (struct sockaddr *)&from, &from_len);
+            if (len > 0) {
+                handle_sap_rx(ctx, rx_buf, len);
+            }
+        }
+
+        expire_remote_sources(ctx);
+    }
+
+    free(rx_buf);
+    ESP_LOGI(TAG, "SAP RX task stopped");
+    vTaskDelete(NULL);
+}
+
+/* TX task: periodically re-announces local sources */
+static void sap_tx_task(void *arg)
+{
+    struct aes67_sap_ctx *ctx = (struct aes67_sap_ctx *)arg;
+    uint8_t tx_buf[SAP_RX_BUF_SIZE];
+
+    ESP_LOGI(TAG, "SAP TX task started");
+
+    while (ctx->running) {
+        /* Announce all active local sources */
+        for (int i = 0; i < SAP_MAX_LOCAL; i++) {
+            if (!ctx->local[i].active) continue;
+
+            int pkt_len = build_sap_packet(tx_buf, sizeof(tx_buf),
+                                           SAP_ANNOUNCE_FLAG,
+                                           ctx->local[i].msg_id,
+                                           ctx->local[i].origin_ip,
+                                           ctx->local[i].sdp);
+            if (pkt_len > 0) {
+                send_sap_packet(ctx->sock, tx_buf, pkt_len);
+            }
+        }
+
+        /* Wait for the next announcement interval, checking running flag */
+        for (int i = 0; i < SAP_TX_INTERVAL_MS / 100 && ctx->running; i++) {
+            vTaskDelay(pdMS_TO_TICKS(100));
+        }
+    }
+
+    ESP_LOGI(TAG, "SAP TX task stopped");
+    vTaskDelete(NULL);
+}
+
+esp_err_t aes67_sap_init(aes67_sap_handle_t *handle)
+{
+    if (!handle) return ESP_ERR_INVALID_ARG;
+
+    struct aes67_sap_ctx *ctx = calloc(1, sizeof(struct aes67_sap_ctx));
+    if (!ctx) {
+        ESP_LOGE(TAG, "Failed to allocate SAP context");
+        return ESP_ERR_NO_MEM;
+    }
+
+    ctx->sock = -1;
+    ctx->running = false;
+    ctx->event_cb = NULL;
+    ctx->user_data = NULL;
+    ctx->local_count = 0;
+    ctx->remote_count = 0;
+
+    *handle = ctx;
+    ESP_LOGI(TAG, "SAP subsystem initialized");
+    return ESP_OK;
+}
+
+esp_err_t aes67_sap_start(aes67_sap_handle_t handle)
+{
+    if (!handle) return ESP_ERR_INVALID_ARG;
+    if (handle->running) return ESP_ERR_INVALID_STATE;
+
+    /* Create UDP socket for SAP */
+    handle->sock = aes67_net_create_udp_socket(AES67_SAP_PORT, true);
+    if (handle->sock < 0) {
+        ESP_LOGE(TAG, "Failed to create SAP socket");
+        return ESP_FAIL;
+    }
+
+    /* Get local IP first so we can bind multicast to the correct interface */
+    uint32_t local_ip = 0;
+    aes67_net_get_local_ip(&local_ip);
+    char local_ip_str[16];
+    aes67_net_u32_to_ip(local_ip, local_ip_str, sizeof(local_ip_str));
+
+    /* Join the SAP multicast group on the local interface (not INADDR_ANY) */
+    esp_err_t err = aes67_net_join_multicast(handle->sock,
+                                              AES67_SAP_MCAST_ADDR,
+                                              local_ip_str);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "Failed to join SAP multicast group");
+        close(handle->sock);
+        handle->sock = -1;
+        return err;
+    }
+
+    /* RFC 2974 requires SAP packets to have TTL=255 */
+    aes67_net_set_multicast_ttl(handle->sock, 255);
+
+    /* Disable multicast loopback so we don't receive our own announcements */
+    int loop = 0;
+    setsockopt(handle->sock, IPPROTO_IP, IP_MULTICAST_LOOP,
+               &loop, sizeof(loop));
+
+    /* Set the outgoing multicast interface to our local IP so packets
+     * go out via Ethernet, not a random interface */
+    struct in_addr mcast_if = { .s_addr = local_ip };
+    setsockopt(handle->sock, IPPROTO_IP, IP_MULTICAST_IF,
+               &mcast_if, sizeof(mcast_if));
+
+    handle->running = true;
+
+    /* Start RX task */
+    BaseType_t ret = xTaskCreate(sap_rx_task, "sap_rx", SAP_RX_TASK_STACK,
+                                 handle, SAP_RX_TASK_PRIO, &handle->rx_task);
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "Failed to create SAP RX task");
+        handle->running = false;
+        close(handle->sock);
+        handle->sock = -1;
+        return ESP_ERR_NO_MEM;
+    }
+
+    /* Start TX task */
+    ret = xTaskCreate(sap_tx_task, "sap_tx", SAP_TX_TASK_STACK,
+                      handle, SAP_TX_TASK_PRIO, &handle->tx_task);
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "Failed to create SAP TX task");
+        handle->running = false;
+        /* Wait a bit for RX task to notice and exit */
+        vTaskDelay(pdMS_TO_TICKS(200));
+        close(handle->sock);
+        handle->sock = -1;
+        return ESP_ERR_NO_MEM;
+    }
+
+    ESP_LOGI(TAG, "SAP started on port %d", AES67_SAP_PORT);
+    return ESP_OK;
+}
+
+esp_err_t aes67_sap_stop(aes67_sap_handle_t handle)
+{
+    if (!handle) return ESP_ERR_INVALID_ARG;
+    if (!handle->running) return ESP_OK;
+
+    ESP_LOGI(TAG, "Stopping SAP...");
+    handle->running = false;
+
+    /* Give tasks time to exit their loops */
+    vTaskDelay(pdMS_TO_TICKS(500));
+
+    if (handle->sock >= 0) {
+        aes67_net_leave_multicast(handle->sock, AES67_SAP_MCAST_ADDR, NULL);
+        close(handle->sock);
+        handle->sock = -1;
+    }
+
+    handle->rx_task = NULL;
+    handle->tx_task = NULL;
+
+    ESP_LOGI(TAG, "SAP stopped");
+    return ESP_OK;
+}
+
+esp_err_t aes67_sap_destroy(aes67_sap_handle_t handle)
+{
+    if (!handle) return ESP_ERR_INVALID_ARG;
+
+    if (handle->running) {
+        aes67_sap_stop(handle);
+    }
+
+    free(handle);
+    ESP_LOGI(TAG, "SAP context destroyed");
+    return ESP_OK;
+}
+
+esp_err_t aes67_sap_announce(aes67_sap_handle_t handle,
+                             uint16_t msg_id, uint32_t origin_ip,
+                             const char *sdp)
+{
+    if (!handle || !sdp) return ESP_ERR_INVALID_ARG;
+
+    /* Check if this msg_id is already registered */
+    for (int i = 0; i < SAP_MAX_LOCAL; i++) {
+        if (handle->local[i].active && handle->local[i].msg_id == msg_id) {
+            /* Update existing entry */
+            strncpy(handle->local[i].sdp, sdp, AES67_SAP_MAX_SDP_LEN - 1);
+            handle->local[i].sdp[AES67_SAP_MAX_SDP_LEN - 1] = '\0';
+            handle->local[i].origin_ip = origin_ip;
+            ESP_LOGI(TAG, "Updated local source announcement (id=0x%04X)", msg_id);
+            return ESP_OK;
+        }
+    }
+
+    /* Find a free slot */
+    for (int i = 0; i < SAP_MAX_LOCAL; i++) {
+        if (!handle->local[i].active) {
+            handle->local[i].active = true;
+            handle->local[i].msg_id = msg_id;
+            handle->local[i].origin_ip = origin_ip;
+            strncpy(handle->local[i].sdp, sdp, AES67_SAP_MAX_SDP_LEN - 1);
+            handle->local[i].sdp[AES67_SAP_MAX_SDP_LEN - 1] = '\0';
+            handle->local_count++;
+            ESP_LOGI(TAG, "Added local source announcement (id=0x%04X)", msg_id);
+
+            /* Send an immediate announcement if already running */
+            if (handle->running && handle->sock >= 0) {
+                uint8_t pkt[SAP_RX_BUF_SIZE];
+                int pkt_len = build_sap_packet(pkt, sizeof(pkt),
+                                               SAP_ANNOUNCE_FLAG,
+                                               msg_id, origin_ip, sdp);
+                if (pkt_len > 0) {
+                    send_sap_packet(handle->sock, pkt, pkt_len);
+                }
+            }
+            return ESP_OK;
+        }
+    }
+
+    ESP_LOGE(TAG, "Local source table full (max %d)", SAP_MAX_LOCAL);
+    return ESP_ERR_NO_MEM;
+}
+
+esp_err_t aes67_sap_delete(aes67_sap_handle_t handle,
+                           uint16_t msg_id, uint32_t origin_ip,
+                           const char *sdp)
+{
+    if (!handle || !sdp) return ESP_ERR_INVALID_ARG;
+
+    /* Send a deletion packet */
+    if (handle->running && handle->sock >= 0) {
+        uint8_t pkt[SAP_RX_BUF_SIZE];
+        int pkt_len = build_sap_packet(pkt, sizeof(pkt),
+                                       SAP_DELETE_FLAG,
+                                       msg_id, origin_ip, sdp);
+        if (pkt_len > 0) {
+            send_sap_packet(handle->sock, pkt, pkt_len);
+        }
+    }
+
+    /* Remove from local source table */
+    for (int i = 0; i < SAP_MAX_LOCAL; i++) {
+        if (handle->local[i].active && handle->local[i].msg_id == msg_id) {
+            handle->local[i].active = false;
+            handle->local_count--;
+            ESP_LOGI(TAG, "Removed local source (id=0x%04X)", msg_id);
+            return ESP_OK;
+        }
+    }
+
+    ESP_LOGW(TAG, "Local source id=0x%04X not found for deletion", msg_id);
+    return ESP_ERR_NOT_FOUND;
+}
+
+esp_err_t aes67_sap_register_cb(aes67_sap_handle_t handle,
+                                aes67_sap_event_cb_t cb, void *user_data)
+{
+    if (!handle) return ESP_ERR_INVALID_ARG;
+    handle->event_cb = cb;
+    handle->user_data = user_data;
+    return ESP_OK;
+}
+
+int aes67_sap_get_remote_count(aes67_sap_handle_t handle)
+{
+    if (!handle) return 0;
+    return handle->remote_count;
+}
+
+esp_err_t aes67_sap_get_remote(aes67_sap_handle_t handle, int index,
+                               aes67_sap_remote_source_t *source)
+{
+    if (!handle || !source) return ESP_ERR_INVALID_ARG;
+    if (index < 0 || index >= handle->remote_count) return ESP_ERR_NOT_FOUND;
+
+    *source = handle->remote[index];
+    return ESP_OK;
+}

+ 412 - 0
components/aes67/src/aes67_sdp.c

@@ -0,0 +1,412 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * SDP (Session Description Protocol) parser and generator for AES67.
+ *
+ * Generates and parses SDP documents conforming to AES67 / RAVENNA
+ * requirements, including PTP reference clock and media clock attributes.
+ */
+
+#include "aes67_sdp.h"
+#include "aes67_net.h"
+
+#include <string.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <ctype.h>
+#include <inttypes.h>
+
+#include "esp_log.h"
+
+static const char *TAG = "aes67_sdp";
+
+const char *aes67_codec_to_str(aes67_codec_t codec)
+{
+    switch (codec) {
+    case AES67_CODEC_L16:   return "L16";
+    case AES67_CODEC_L24:   return "L24";
+    case AES67_CODEC_L32:   return "L32";
+    case AES67_CODEC_AM824: return "AM824";
+    default:                return "L24";
+    }
+}
+
+int aes67_codec_from_str(const char *name)
+{
+    if (!name) return -1;
+    if (strcmp(name, "L16") == 0)   return AES67_CODEC_L16;
+    if (strcmp(name, "L24") == 0)   return AES67_CODEC_L24;
+    if (strcmp(name, "L32") == 0)   return AES67_CODEC_L32;
+    if (strcmp(name, "AM824") == 0) return AES67_CODEC_AM824;
+    return -1;
+}
+
+/* aes67_codec_word_length() is now inline in aes67_config.h */
+
+int aes67_sdp_generate(const aes67_sdp_t *sdp, char *buf, size_t buf_len)
+{
+    if (!sdp || !buf || buf_len == 0) {
+        return -1;
+    }
+
+    char origin_ip_str[16];
+    char conn_ip_str[16];
+    aes67_net_u32_to_ip(sdp->origin_ip, origin_ip_str, sizeof(origin_ip_str));
+    aes67_net_u32_to_ip(sdp->connection_ip, conn_ip_str, sizeof(conn_ip_str));
+
+    const char *codec_str = aes67_codec_to_str(sdp->codec);
+    const char *username = sdp->origin_username[0] ? sdp->origin_username : "-";
+
+    /* Build ptime string. AES67 uses fractional ms for sub-ms packet times. */
+    char ptime_str[16];
+    if (sdp->ptime_us >= 1000 && (sdp->ptime_us % 1000) == 0) {
+        snprintf(ptime_str, sizeof(ptime_str), "%u", sdp->ptime_us / 1000);
+    } else if (sdp->ptime_us >= 1000) {
+        /* e.g. 1500us -> "1.5" */
+        snprintf(ptime_str, sizeof(ptime_str), "%u.%03u",
+                 sdp->ptime_us / 1000, sdp->ptime_us % 1000);
+        /* Strip trailing zeros after decimal point */
+        size_t len = strlen(ptime_str);
+        while (len > 0 && ptime_str[len - 1] == '0') {
+            ptime_str[--len] = '\0';
+        }
+    } else {
+        /* Sub-millisecond: e.g. 125us -> "0.125" */
+        snprintf(ptime_str, sizeof(ptime_str), "0.%03u", sdp->ptime_us);
+        size_t len = strlen(ptime_str);
+        while (len > 0 && ptime_str[len - 1] == '0') {
+            ptime_str[--len] = '\0';
+        }
+    }
+
+    /* Build PTP grandmaster ID string: XX-XX-XX-FF-FE-XX-XX-XX */
+    char ptp_gm_str[24] = {0};
+    if (sdp->has_ptp_refclk) {
+        snprintf(ptp_gm_str, sizeof(ptp_gm_str),
+                 "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+                 sdp->ptp_grandmaster_id[0], sdp->ptp_grandmaster_id[1],
+                 sdp->ptp_grandmaster_id[2], sdp->ptp_grandmaster_id[3],
+                 sdp->ptp_grandmaster_id[4], sdp->ptp_grandmaster_id[5],
+                 sdp->ptp_grandmaster_id[6], sdp->ptp_grandmaster_id[7]);
+    }
+
+    /* Compute samples per packet for the framecount attribute */
+    uint32_t framecount = (sdp->sample_rate * sdp->ptime_us) / 1000000;
+    if (framecount == 0) framecount = 48;
+
+    int written = snprintf(buf, buf_len,
+        "v=0\r\n"
+        "o=%s %" PRIu32 " %" PRIu32 " IN IP4 %s\r\n"
+        "s=%s\r\n"
+        "c=IN IP4 %s/%u\r\n"
+        "t=0 0\r\n"
+        "m=audio %u RTP/AVP %u\r\n"
+        "i=Channels 1-%u\r\n"
+        "a=sync-time:0\r\n"
+        "a=framecount:%" PRIu32 "\r\n"
+        "a=rtpmap:%u %s/%" PRIu32 "/%u\r\n"
+        "a=ptime:%s\r\n"
+        "a=%s\r\n",
+        username, sdp->session_id, sdp->session_version, origin_ip_str,
+        sdp->session_name,
+        conn_ip_str, sdp->ttl,
+        sdp->port, sdp->payload_type,
+        sdp->channels,
+        framecount,
+        sdp->payload_type, codec_str, sdp->sample_rate, sdp->channels,
+        ptime_str,
+        sdp->is_source ? "sendonly" : "recvonly");
+
+    if (written < 0 || (size_t)written >= buf_len) {
+        ESP_LOGE(TAG, "SDP buffer overflow during generate");
+        return -1;
+    }
+
+    /* Append PTP reference clock attribute.
+     * Use "traceable" when the GM is known and traceable,
+     * otherwise include the specific GM identity. */
+    if (sdp->has_ptp_refclk) {
+        int n;
+        if (sdp->ptp_traceable) {
+            n = snprintf(buf + written, buf_len - written,
+                         "a=ts-refclk:ptp=IEEE1588-2008:traceable\r\n");
+        } else {
+            n = snprintf(buf + written, buf_len - written,
+                         "a=ts-refclk:ptp=IEEE1588-2008:%s:%u\r\n",
+                         ptp_gm_str, sdp->ptp_domain);
+        }
+        if (n < 0 || (size_t)(written + n) >= buf_len) {
+            return -1;
+        }
+        written += n;
+    }
+
+    /* Append media clock attribute */
+    if (sdp->has_mediaclk) {
+        int n = snprintf(buf + written, buf_len - written,
+                         "a=mediaclk:direct=%" PRIu32 "\r\n",
+                         sdp->mediaclk_offset);
+        if (n < 0 || (size_t)(written + n) >= buf_len) {
+            return -1;
+        }
+        written += n;
+    }
+
+    /* Append clock domain */
+    {
+        int n = snprintf(buf + written, buf_len - written,
+                         "a=clock-domain:PTPv2 %u\r\n",
+                         sdp->ptp_domain);
+        if (n < 0 || (size_t)(written + n) >= buf_len) {
+            return -1;
+        }
+        written += n;
+    }
+
+    return written;
+}
+
+/* Parse a single PTP grandmaster ID from "XX-XX-XX-XX-XX-XX-XX-XX" format */
+static bool parse_ptp_gmid(const char *str, uint8_t gm[8])
+{
+    if (strlen(str) < 23) return false;
+
+    unsigned int vals[8];
+    int matched = sscanf(str, "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+                         &vals[0], &vals[1], &vals[2], &vals[3],
+                         &vals[4], &vals[5], &vals[6], &vals[7]);
+    if (matched != 8) return false;
+
+    for (int i = 0; i < 8; i++) {
+        gm[i] = (uint8_t)vals[i];
+    }
+    return true;
+}
+
+/* Parse ptime value which may be fractional (e.g. "1", "0.125", "1.5") */
+static uint16_t parse_ptime_us(const char *str)
+{
+    /* Split on decimal point */
+    const char *dot = strchr(str, '.');
+    if (!dot) {
+        /* Integer milliseconds */
+        return (uint16_t)(atoi(str) * 1000);
+    }
+
+    /* Integer part in ms */
+    int integer_ms = atoi(str);
+
+    /* Fractional part - pad to 3 digits for microseconds */
+    const char *frac = dot + 1;
+    int frac_val = 0;
+    int digits = 0;
+    while (*frac && isdigit((unsigned char)*frac) && digits < 3) {
+        frac_val = frac_val * 10 + (*frac - '0');
+        frac++;
+        digits++;
+    }
+    /* Pad remaining digits to get microseconds */
+    while (digits < 3) {
+        frac_val *= 10;
+        digits++;
+    }
+
+    return (uint16_t)(integer_ms * 1000 + frac_val);
+}
+
+/* Parse a=rtpmap line, e.g. "98 L24/48000/2" */
+static void parse_rtpmap(const char *val, aes67_sdp_t *sdp)
+{
+    unsigned int pt;
+    char codec_name[16];
+    unsigned int rate, ch;
+
+    int matched = sscanf(val, "%u %15[^/]/%u/%u", &pt, codec_name, &rate, &ch);
+    if (matched >= 3) {
+        sdp->payload_type = (uint8_t)pt;
+        int codec = aes67_codec_from_str(codec_name);
+        if (codec >= 0) {
+            sdp->codec = (aes67_codec_t)codec;
+            sdp->word_length = aes67_codec_word_length(sdp->codec);
+        }
+        sdp->sample_rate = rate;
+        sdp->channels = (matched >= 4) ? (uint8_t)ch : 1;
+    }
+}
+
+/* Parse a=ts-refclk line, e.g. "ptp=IEEE1588-2008:XX-XX-...:0" */
+static void parse_ts_refclk(const char *val, aes67_sdp_t *sdp)
+{
+    /* Check for PTP reference clock */
+    if (strncmp(val, "ptp=IEEE1588-2008:", 18) != 0) {
+        /* Could be "ptp=traceable" */
+        if (strcmp(val, "ptp=traceable") == 0) {
+            sdp->has_ptp_refclk = true;
+            sdp->ptp_traceable = true;
+        }
+        return;
+    }
+
+    const char *gm_start = val + 18;
+    if (parse_ptp_gmid(gm_start, sdp->ptp_grandmaster_id)) {
+        sdp->has_ptp_refclk = true;
+    }
+
+    /* Parse domain after the GM ID: "XX-XX-XX-XX-XX-XX-XX-XX:0" */
+    const char *colon = strchr(gm_start + 23, ':');
+    if (colon) {
+        sdp->ptp_domain = (uint8_t)atoi(colon + 1);
+    }
+}
+
+/* Parse a=mediaclk line, e.g. "direct=0" */
+static void parse_mediaclk(const char *val, aes67_sdp_t *sdp)
+{
+    if (strncmp(val, "direct=", 7) == 0) {
+        sdp->has_mediaclk = true;
+        sdp->mediaclk_offset = (uint32_t)strtoul(val + 7, NULL, 10);
+    }
+}
+
+/* Parse origin line: "- 12345 1 IN IP4 192.168.1.10" */
+static void parse_origin(const char *val, aes67_sdp_t *sdp)
+{
+    char username[32];
+    unsigned int sid, sver;
+    char net_type[4], addr_type[8], addr[16];
+
+    int matched = sscanf(val, "%31s %u %u %3s %7s %15s",
+                         username, &sid, &sver, net_type, addr_type, addr);
+    if (matched >= 6) {
+        strncpy(sdp->origin_username, username, sizeof(sdp->origin_username) - 1);
+        sdp->origin_username[sizeof(sdp->origin_username) - 1] = '\0';
+        sdp->session_id = sid;
+        sdp->session_version = sver;
+        sdp->origin_ip = aes67_net_ip_to_u32(addr);
+    }
+}
+
+/* Parse connection line: "IN IP4 239.69.0.1/32" */
+static void parse_connection(const char *val, aes67_sdp_t *sdp)
+{
+    char net_type[4], addr_type[8], addr_with_ttl[32];
+
+    int matched = sscanf(val, "%3s %7s %31s", net_type, addr_type, addr_with_ttl);
+    if (matched < 3) return;
+
+    /* Split address and TTL at '/' */
+    char *slash = strchr(addr_with_ttl, '/');
+    if (slash) {
+        *slash = '\0';
+        sdp->ttl = (uint8_t)atoi(slash + 1);
+    }
+    sdp->connection_ip = aes67_net_ip_to_u32(addr_with_ttl);
+}
+
+/* Parse media line: "audio 5004 RTP/AVP 98" */
+static void parse_media(const char *val, aes67_sdp_t *sdp)
+{
+    char media_type[16], transport[16];
+    unsigned int port, pt;
+
+    int matched = sscanf(val, "%15s %u %15s %u", media_type, &port, transport, &pt);
+    if (matched >= 4 && strcmp(media_type, "audio") == 0) {
+        sdp->port = (uint16_t)port;
+        sdp->payload_type = (uint8_t)pt;
+    }
+}
+
+/* Process a single attribute (a=) line */
+static void parse_attribute(const char *val, aes67_sdp_t *sdp)
+{
+    if (strncmp(val, "rtpmap:", 7) == 0) {
+        parse_rtpmap(val + 7, sdp);
+    } else if (strncmp(val, "ptime:", 6) == 0) {
+        sdp->ptime_us = parse_ptime_us(val + 6);
+    } else if (strncmp(val, "ts-refclk:", 10) == 0) {
+        parse_ts_refclk(val + 10, sdp);
+    } else if (strncmp(val, "mediaclk:", 9) == 0) {
+        parse_mediaclk(val + 9, sdp);
+    }
+}
+
+esp_err_t aes67_sdp_parse(const char *sdp_text, aes67_sdp_t *sdp)
+{
+    if (!sdp_text || !sdp) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    memset(sdp, 0, sizeof(aes67_sdp_t));
+
+    /* Work on a mutable copy for line tokenization */
+    size_t text_len = strlen(sdp_text);
+    if (text_len == 0 || text_len >= AES67_SDP_MAX_LEN) {
+        ESP_LOGE(TAG, "SDP text empty or too long (%u bytes)", (unsigned)text_len);
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    char buf[AES67_SDP_MAX_LEN];
+    memcpy(buf, sdp_text, text_len + 1);
+
+    /* Parse line by line. Handle both \r\n and \n line endings. */
+    char *saveptr = NULL;
+    char *line = strtok_r(buf, "\n", &saveptr);
+
+    while (line) {
+        /* Strip trailing \r if present */
+        size_t len = strlen(line);
+        if (len > 0 && line[len - 1] == '\r') {
+            line[len - 1] = '\0';
+            len--;
+        }
+
+        /* Each SDP line must be at least "X=Y" (3 chars) */
+        if (len >= 2 && line[1] == '=') {
+            const char *val = line + 2;
+            switch (line[0]) {
+            case 'o':
+                parse_origin(val, sdp);
+                break;
+            case 's':
+                strncpy(sdp->session_name, val, sizeof(sdp->session_name) - 1);
+                sdp->session_name[sizeof(sdp->session_name) - 1] = '\0';
+                break;
+            case 'c':
+                parse_connection(val, sdp);
+                break;
+            case 'm':
+                parse_media(val, sdp);
+                break;
+            case 'a':
+                parse_attribute(val, sdp);
+                break;
+            default:
+                /* v=, t= and others we silently skip */
+                break;
+            }
+        }
+
+        line = strtok_r(NULL, "\n", &saveptr);
+    }
+
+    /* Sanity check: at minimum we need a port and sample rate */
+    if (sdp->port == 0 || sdp->sample_rate == 0) {
+        ESP_LOGW(TAG, "SDP parse incomplete: port=%u rate=%u",
+                 sdp->port, sdp->sample_rate);
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    /* Derive word length from codec if not set */
+    if (sdp->word_length == 0) {
+        sdp->word_length = aes67_codec_word_length(sdp->codec);
+    }
+
+    ESP_LOGD(TAG, "Parsed SDP: %s, %s/%u/%u, port %u, ptime %u us",
+             sdp->session_name, aes67_codec_to_str(sdp->codec),
+             sdp->sample_rate, sdp->channels, sdp->port, sdp->ptime_us);
+
+    return ESP_OK;
+}

+ 611 - 0
components/aes67/src/aes67_session.c

@@ -0,0 +1,611 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67 session manager implementation.
+ *
+ * Manages the lifecycle of source and sink streams, including SDP
+ * generation, SAP announcements, and RTP stream creation/teardown.
+ */
+
+#include <string.h>
+#include <stdlib.h>
+#include <arpa/inet.h>
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "esp_log.h"
+
+#include "aes67_session.h"
+#include "aes67_rtp.h"
+#include "aes67_sdp.h"
+#include "aes67_sap.h"
+#include "aes67_ptp.h"
+#include "aes67_audio.h"
+#include "aes67_net.h"
+#include "aes67_config.h"
+
+static const char *TAG = "aes67_session";
+
+/* Session manager internal state */
+struct aes67_session_mgr {
+    aes67_rtp_engine_handle_t rtp;
+    aes67_ptp_handle_t ptp;
+    aes67_sap_handle_t sap;
+    aes67_audio_handle_t audio;
+    aes67_config_t config;
+
+    aes67_source_t sources[CONFIG_AES67_MAX_SOURCES];
+    aes67_sink_t sinks[CONFIG_AES67_MAX_SINKS];
+    uint8_t source_count;
+    uint8_t sink_count;
+
+    uint32_t next_mcast_ip;       /* auto-incrementing multicast address */
+    uint32_t session_id_counter;
+
+    SemaphoreHandle_t lock;
+};
+
+/* Generate a deterministic SSRC from MAC address and stream ID */
+static uint32_t generate_ssrc(uint8_t stream_id)
+{
+    uint8_t mac[6];
+    if (aes67_net_get_local_mac(mac) != ESP_OK) {
+        /* Fallback: use stream ID only */
+        return (uint32_t)stream_id | 0xAE670000;
+    }
+
+    /* FNV-1a 32-bit hash of MAC + stream ID */
+    uint32_t hash = 2166136261u;
+    for (int i = 0; i < 6; i++) {
+        hash ^= mac[i];
+        hash *= 16777619u;
+    }
+    hash ^= stream_id;
+    hash *= 16777619u;
+
+    /* Avoid reserved SSRC value 0 */
+    if (hash == 0) {
+        hash = 1;
+    }
+    return hash;
+}
+
+esp_err_t aes67_session_init(aes67_rtp_engine_handle_t rtp,
+                             aes67_ptp_handle_t ptp,
+                             aes67_sap_handle_t sap,
+                             aes67_audio_handle_t audio,
+                             const aes67_config_t *config,
+                             aes67_session_handle_t *handle)
+{
+    if (!rtp || !ptp || !config || !handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = calloc(1, sizeof(*mgr));
+    if (!mgr) {
+        return ESP_ERR_NO_MEM;
+    }
+
+    mgr->rtp = rtp;
+    mgr->ptp = ptp;
+    mgr->sap = sap;
+    mgr->audio = audio;
+    memcpy(&mgr->config, config, sizeof(aes67_config_t));
+
+    mgr->source_count = 0;
+    mgr->sink_count = 0;
+    mgr->session_id_counter = 1;
+
+    /* Parse the multicast base address for auto-incrementing */
+    mgr->next_mcast_ip = aes67_net_ip_to_u32(config->net.rtp_mcast_base);
+
+    mgr->lock = xSemaphoreCreateMutex();
+    if (!mgr->lock) {
+        free(mgr);
+        return ESP_ERR_NO_MEM;
+    }
+
+    /* Clear all slots */
+    memset(mgr->sources, 0, sizeof(mgr->sources));
+    memset(mgr->sinks, 0, sizeof(mgr->sinks));
+
+    *handle = mgr;
+    ESP_LOGI(TAG, "session manager initialized");
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_add_source(aes67_session_handle_t handle,
+                                   const char *name,
+                                   uint8_t channels,
+                                   aes67_codec_t codec,
+                                   uint8_t *id)
+{
+    if (!handle || !name || !id) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    /* Find a free slot */
+    int slot = -1;
+    for (int i = 0; i < CONFIG_AES67_MAX_SOURCES; i++) {
+        if (!mgr->sources[i].enabled) {
+            slot = i;
+            break;
+        }
+    }
+
+    if (slot < 0) {
+        xSemaphoreGive(mgr->lock);
+        ESP_LOGE(TAG, "no free source slots");
+        return ESP_ERR_NO_MEM;
+    }
+
+    /* Use defaults where not specified */
+    if (channels == 0) {
+        channels = mgr->config.audio.channels;
+    }
+    if ((int)codec < 0) {
+        codec = mgr->config.audio.codec;
+    }
+
+    uint8_t word_length = aes67_codec_word_length(codec);
+    uint32_t sample_rate = mgr->config.audio.sample_rate;
+    uint16_t packet_time = mgr->config.audio.packet_time_us;
+    uint16_t port = mgr->config.net.rtp_port;
+
+    /* Compute multicast IP: base address + source index (network order).
+     * The base IP is already in network byte order from aes67_net_ip_to_u32,
+     * so we increment the host-order representation. */
+    uint32_t mcast_ip = ntohl(mgr->next_mcast_ip) + (uint32_t)slot;
+    mcast_ip = htonl(mcast_ip);
+
+    uint32_t ssrc = generate_ssrc((uint8_t)slot);
+
+    /* Build RTP stream config */
+    aes67_rtp_stream_config_t rtp_cfg;
+    memset(&rtp_cfg, 0, sizeof(rtp_cfg));
+    rtp_cfg.direction = AES67_STREAM_SOURCE;
+    rtp_cfg.dest_ip = mcast_ip;
+    rtp_cfg.dest_port = port;
+    rtp_cfg.src_port = port;
+    rtp_cfg.payload_type = 98;
+    rtp_cfg.ssrc = ssrc;
+    rtp_cfg.sample_rate = sample_rate;
+    rtp_cfg.channels = channels;
+    rtp_cfg.word_length = word_length;
+    rtp_cfg.codec = codec;
+    rtp_cfg.packet_time_us = packet_time;
+    rtp_cfg.playout_delay_us = 0;
+    rtp_cfg.dscp = mgr->config.net.dscp;
+    rtp_cfg.ttl = mgr->config.net.ttl;
+
+    /* Default 1:1 channel routing */
+    for (int i = 0; i < channels && i < CONFIG_AES67_MAX_CHANNELS_PER_STREAM; i++) {
+        rtp_cfg.routing[i] = (uint8_t)i;
+    }
+
+    /* Build SDP */
+    aes67_sdp_t sdp;
+    memset(&sdp, 0, sizeof(sdp));
+
+    uint32_t local_ip = 0;
+    aes67_net_get_local_ip(&local_ip);
+
+    strncpy(sdp.origin_username, "-", sizeof(sdp.origin_username) - 1);
+    sdp.session_id = mgr->session_id_counter++;
+    sdp.session_version = 1;
+    sdp.origin_ip = local_ip;
+
+    strncpy(sdp.session_name, name, sizeof(sdp.session_name) - 1);
+    sdp.connection_ip = mcast_ip;
+    sdp.ttl = mgr->config.net.ttl;
+    sdp.port = port;
+    sdp.payload_type = 98;
+    sdp.codec = codec;
+    sdp.sample_rate = sample_rate;
+    sdp.channels = channels;
+    sdp.word_length = word_length;
+    sdp.ptime_us = packet_time;
+
+    /* PTP reference clock. At stream creation time we may not have
+     * synced to a GM yet, so default to "traceable" which is what
+     * most AES67 devices advertise. The SDP will be re-announced
+     * via SAP periodically, picking up the current GM state. */
+    sdp.is_source = true;  /* This is a TX source: a=sendonly */
+    sdp.has_ptp_refclk = true;
+    sdp.ptp_traceable = true;
+    sdp.ptp_domain = mgr->config.ptp.domain;
+
+    aes67_ptp_status_t ptp_status;
+    if (aes67_ptp_get_status(mgr->ptp, &ptp_status) == ESP_OK &&
+        ptp_status.lock_state == AES67_PTP_LOCKED) {
+        /* If we have a locked GM, include its identity */
+        bool gm_valid = false;
+        for (int i = 0; i < 8; i++) {
+            if (ptp_status.grandmaster_id[i] != 0) { gm_valid = true; break; }
+        }
+        if (gm_valid) {
+            memcpy(sdp.ptp_grandmaster_id, ptp_status.grandmaster_id, 8);
+            sdp.ptp_traceable = true;
+        }
+    }
+
+    /* Media clock offset */
+    sdp.has_mediaclk = true;
+    sdp.mediaclk_offset = 0;
+
+    /* Generate SDP text into a temporary buffer */
+    char sdp_text[AES67_SDP_MAX_LEN];
+    int sdp_len = aes67_sdp_generate(&sdp, sdp_text, sizeof(sdp_text));
+    if (sdp_len < 0) {
+        xSemaphoreGive(mgr->lock);
+        ESP_LOGE(TAG, "failed to generate SDP for source '%s'", name);
+        return ESP_FAIL;
+    }
+
+    /* Add the RTP stream */
+    aes67_rtp_stream_handle_t rtp_stream = NULL;
+    esp_err_t err = aes67_rtp_stream_add(mgr->rtp, &rtp_cfg, &rtp_stream);
+    if (err != ESP_OK) {
+        xSemaphoreGive(mgr->lock);
+        ESP_LOGE(TAG, "failed to add RTP stream for source '%s': %s",
+                 name, esp_err_to_name(err));
+        return err;
+    }
+
+    /* Compute CRC16 of the SDP text as the SAP message ID hash,
+     * matching the Linux daemon convention. Always compute so we can
+     * store it for later use during SAP delete. */
+    uint16_t msg_crc = 0xFFFF;
+    for (int i = 0; i < sdp_len; i++) {
+        msg_crc ^= (uint8_t)sdp_text[i];
+        for (int b = 0; b < 8; b++) {
+            if (msg_crc & 1) msg_crc = (msg_crc >> 1) ^ 0xA001;
+            else msg_crc >>= 1;
+        }
+    }
+
+    /* SAP announcement if enabled */
+    if (mgr->config.sap_enabled && mgr->sap) {
+        err = aes67_sap_announce(mgr->sap, msg_crc,
+                                 local_ip, sdp_text);
+        if (err != ESP_OK) {
+            ESP_LOGW(TAG, "SAP announce failed for source '%s': %s",
+                     name, esp_err_to_name(err));
+            /* Non-fatal: stream still works without SAP */
+        }
+    }
+
+    /* Store in source slot */
+    aes67_source_t *src = &mgr->sources[slot];
+    src->id = (uint8_t)slot;
+    strncpy(src->name, name, sizeof(src->name) - 1);
+    src->name[sizeof(src->name) - 1] = '\0';
+    memcpy(&src->rtp_config, &rtp_cfg, sizeof(rtp_cfg));
+    memcpy(&src->sdp, &sdp, sizeof(sdp));
+    src->rtp_stream = rtp_stream;
+    src->sap_msg_id = msg_crc;
+    src->enabled = true;
+
+    mgr->source_count++;
+    *id = (uint8_t)slot;
+
+    xSemaphoreGive(mgr->lock);
+
+    ESP_LOGI(TAG, "source '%s' added (id=%d, channels=%d, ssrc=0x%08lx)",
+             name, slot, channels, (unsigned long)ssrc);
+    ESP_LOGI(TAG, "SDP:\n%s", sdp_text);
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_add_sink(aes67_session_handle_t handle,
+                                 const char *name,
+                                 const char *sdp_text,
+                                 uint8_t *id)
+{
+    if (!handle || !name || !sdp_text || !id) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    /* Parse the remote SDP */
+    aes67_sdp_t sdp;
+    memset(&sdp, 0, sizeof(sdp));
+    esp_err_t err = aes67_sdp_parse(sdp_text, &sdp);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "failed to parse SDP for sink '%s'", name);
+        return err;
+    }
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    /* Find a free slot */
+    int slot = -1;
+    for (int i = 0; i < CONFIG_AES67_MAX_SINKS; i++) {
+        if (!mgr->sinks[i].enabled) {
+            slot = i;
+            break;
+        }
+    }
+
+    if (slot < 0) {
+        xSemaphoreGive(mgr->lock);
+        ESP_LOGE(TAG, "no free sink slots");
+        return ESP_ERR_NO_MEM;
+    }
+
+    /* Build RTP stream config from parsed SDP */
+    aes67_rtp_stream_config_t rtp_cfg;
+    memset(&rtp_cfg, 0, sizeof(rtp_cfg));
+    rtp_cfg.direction = AES67_STREAM_SINK;
+    rtp_cfg.dest_ip = sdp.connection_ip;
+    rtp_cfg.dest_port = sdp.port;
+    rtp_cfg.src_port = sdp.port;
+    rtp_cfg.payload_type = sdp.payload_type;
+    rtp_cfg.ssrc = 0;  /* Will match any SSRC from remote source */
+    rtp_cfg.sample_rate = sdp.sample_rate;
+    rtp_cfg.channels = sdp.channels;
+    rtp_cfg.word_length = sdp.word_length;
+    rtp_cfg.codec = sdp.codec;
+    rtp_cfg.packet_time_us = sdp.ptime_us;
+    rtp_cfg.playout_delay_us = (uint32_t)mgr->config.jitter_buffer_ms * 1000;
+    rtp_cfg.dscp = mgr->config.net.dscp;
+    rtp_cfg.ttl = mgr->config.net.ttl;
+
+    /* Default 1:1 channel routing */
+    for (int i = 0; i < sdp.channels && i < CONFIG_AES67_MAX_CHANNELS_PER_STREAM; i++) {
+        rtp_cfg.routing[i] = (uint8_t)i;
+    }
+
+    /* Add the RTP stream */
+    aes67_rtp_stream_handle_t rtp_stream = NULL;
+    err = aes67_rtp_stream_add(mgr->rtp, &rtp_cfg, &rtp_stream);
+    if (err != ESP_OK) {
+        xSemaphoreGive(mgr->lock);
+        ESP_LOGE(TAG, "failed to add RTP stream for sink '%s': %s",
+                 name, esp_err_to_name(err));
+        return err;
+    }
+
+    /* Store in sink slot */
+    aes67_sink_t *snk = &mgr->sinks[slot];
+    snk->id = (uint8_t)slot;
+    strncpy(snk->name, name, sizeof(snk->name) - 1);
+    snk->name[sizeof(snk->name) - 1] = '\0';
+    memcpy(&snk->rtp_config, &rtp_cfg, sizeof(rtp_cfg));
+    memcpy(&snk->sdp, &sdp, sizeof(sdp));
+    snk->rtp_stream = rtp_stream;
+    snk->enabled = true;
+
+    /* Store original SDP text for reference */
+    strncpy(snk->remote_sdp, sdp_text, AES67_SDP_MAX_LEN - 1);
+    snk->remote_sdp[AES67_SDP_MAX_LEN - 1] = '\0';
+
+    mgr->sink_count++;
+    *id = (uint8_t)slot;
+
+    xSemaphoreGive(mgr->lock);
+
+    ESP_LOGI(TAG, "sink '%s' added (id=%d, %s/%luHz/%dch, wl=%d, ptime=%uus, "
+             "mcast=%s:%u, pt=%u)",
+             name, slot,
+             aes67_codec_to_str(sdp.codec),
+             (unsigned long)sdp.sample_rate, sdp.channels, sdp.word_length,
+             sdp.ptime_us,
+             sdp.session_name, sdp.port, sdp.payload_type);
+    ESP_LOGI(TAG, "sink SDP:\n%s", sdp_text);
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_remove_source(aes67_session_handle_t handle, uint8_t id)
+{
+    if (!handle || id >= CONFIG_AES67_MAX_SOURCES) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    aes67_source_t *src = &mgr->sources[id];
+    if (!src->enabled) {
+        xSemaphoreGive(mgr->lock);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    /* Send SAP deletion if enabled */
+    if (mgr->config.sap_enabled && mgr->sap) {
+        uint32_t local_ip = 0;
+        aes67_net_get_local_ip(&local_ip);
+
+        char sdp_text[AES67_SDP_MAX_LEN];
+        int len = aes67_sdp_generate(&src->sdp, sdp_text, sizeof(sdp_text));
+        if (len > 0) {
+            esp_err_t err = aes67_sap_delete(mgr->sap,
+                                             src->sap_msg_id,
+                                             local_ip, sdp_text);
+            if (err != ESP_OK) {
+                ESP_LOGW(TAG, "SAP delete failed for source %d: %s",
+                         id, esp_err_to_name(err));
+            }
+        }
+    }
+
+    /* Remove RTP stream */
+    if (src->rtp_stream) {
+        aes67_rtp_stream_remove(mgr->rtp, src->rtp_stream);
+    }
+
+    /* Clear slot */
+    char name_copy[64];
+    strlcpy(name_copy, src->name, sizeof(name_copy));
+    memset(src, 0, sizeof(*src));
+    mgr->source_count--;
+
+    xSemaphoreGive(mgr->lock);
+
+    ESP_LOGI(TAG, "source '%s' removed (id=%d)", name_copy, id);
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_remove_sink(aes67_session_handle_t handle, uint8_t id)
+{
+    if (!handle || id >= CONFIG_AES67_MAX_SINKS) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    aes67_sink_t *snk = &mgr->sinks[id];
+    if (!snk->enabled) {
+        xSemaphoreGive(mgr->lock);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    /* Remove RTP stream */
+    if (snk->rtp_stream) {
+        aes67_rtp_stream_remove(mgr->rtp, snk->rtp_stream);
+    }
+
+    /* Clear slot */
+    char name_copy[64];
+    strlcpy(name_copy, snk->name, sizeof(name_copy));
+    memset(snk, 0, sizeof(*snk));
+    mgr->sink_count--;
+
+    xSemaphoreGive(mgr->lock);
+
+    ESP_LOGI(TAG, "sink '%s' removed (id=%d)", name_copy, id);
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_get_source(aes67_session_handle_t handle, uint8_t id,
+                                   aes67_source_t *source)
+{
+    if (!handle || !source || id >= CONFIG_AES67_MAX_SOURCES) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    if (!mgr->sources[id].enabled) {
+        xSemaphoreGive(mgr->lock);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    memcpy(source, &mgr->sources[id], sizeof(aes67_source_t));
+    xSemaphoreGive(mgr->lock);
+    return ESP_OK;
+}
+
+esp_err_t aes67_session_get_sink(aes67_session_handle_t handle, uint8_t id,
+                                 aes67_sink_t *sink)
+{
+    if (!handle || !sink || id >= CONFIG_AES67_MAX_SINKS) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(1000)) != pdTRUE) {
+        return ESP_ERR_TIMEOUT;
+    }
+
+    if (!mgr->sinks[id].enabled) {
+        xSemaphoreGive(mgr->lock);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    memcpy(sink, &mgr->sinks[id], sizeof(aes67_sink_t));
+    xSemaphoreGive(mgr->lock);
+    return ESP_OK;
+}
+
+int aes67_session_get_source_count(aes67_session_handle_t handle)
+{
+    if (!handle) {
+        return 0;
+    }
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(100)) != pdTRUE) {
+        return 0;
+    }
+    int count = mgr->source_count;
+    xSemaphoreGive(mgr->lock);
+    return count;
+}
+
+int aes67_session_get_sink_count(aes67_session_handle_t handle)
+{
+    if (!handle) {
+        return 0;
+    }
+    struct aes67_session_mgr *mgr = handle;
+
+    if (xSemaphoreTake(mgr->lock, pdMS_TO_TICKS(100)) != pdTRUE) {
+        return 0;
+    }
+    int count = mgr->sink_count;
+    xSemaphoreGive(mgr->lock);
+    return count;
+}
+
+void aes67_session_set_packet_time(aes67_session_handle_t handle, uint16_t ptime_us)
+{
+    if (handle && ptime_us > 0) {
+        struct aes67_session_mgr *mgr = handle;
+        mgr->config.audio.packet_time_us = ptime_us;
+    }
+}
+
+esp_err_t aes67_session_destroy(aes67_session_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_session_mgr *mgr = handle;
+
+    /* Remove all active sources */
+    for (int i = 0; i < CONFIG_AES67_MAX_SOURCES; i++) {
+        if (mgr->sources[i].enabled) {
+            aes67_session_remove_source(handle, (uint8_t)i);
+        }
+    }
+
+    /* Remove all active sinks */
+    for (int i = 0; i < CONFIG_AES67_MAX_SINKS; i++) {
+        if (mgr->sinks[i].enabled) {
+            aes67_session_remove_sink(handle, (uint8_t)i);
+        }
+    }
+
+    if (mgr->lock) {
+        vSemaphoreDelete(mgr->lock);
+    }
+
+    free(mgr);
+    ESP_LOGI(TAG, "session manager destroyed");
+    return ESP_OK;
+}

+ 1028 - 0
components/aes67/src/aes67_webui.c

@@ -0,0 +1,1028 @@
+/*
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/*
+ * AES67 Web UI - HTTP server and WebSocket backend.
+ *
+ * Provides a REST API for device status and stream management,
+ * serves an embedded gzipped SPA, and pushes real-time status
+ * updates over WebSocket at 2Hz.
+ *
+ * All data reads use copy-out accessor functions from the node/session/
+ * PTP/SAP subsystems. No direct struct access, no mutex contention
+ * with audio paths.
+ */
+
+#include "aes67_webui.h"
+#include "aes67.h"
+#include "aes67_config.h"
+#include "aes67_session.h"
+#include "aes67_ptp.h"
+#include "aes67_rtp.h"
+#include "aes67_sap.h"
+#include "aes67_sdp.h"
+#include "aes67_net.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+#include "esp_log.h"
+#include "esp_http_server.h"
+#include "esp_timer.h"
+#include "esp_heap_caps.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+static const char *TAG = "aes67_webui";
+
+/* Embedded gzipped index.html from EMBED_FILES */
+extern const uint8_t index_html_gz_start[] asm("_binary_index_html_gz_start");
+extern const uint8_t index_html_gz_end[]   asm("_binary_index_html_gz_end");
+
+/* JSON response buffer size for REST endpoints and WS pushes */
+#define JSON_BUF_SIZE 2048
+
+/* ---- Context ---- */
+
+struct aes67_webui {
+    aes67_node_handle_t node;
+    httpd_handle_t      httpd;
+    int                 ws_fd;      /* Active WebSocket fd (-1 if none) */
+    TaskHandle_t        ws_task;    /* 2Hz status push task */
+    uint16_t            port;
+    bool                running;
+};
+
+/* ---- Codec name helper ---- */
+
+static const char *codec_name(aes67_codec_t codec)
+{
+    switch (codec) {
+    case AES67_CODEC_L16:   return "L16";
+    case AES67_CODEC_L24:   return "L24";
+    case AES67_CODEC_L32:   return "L32";
+    case AES67_CODEC_AM824: return "AM824";
+    default:                return "unknown";
+    }
+}
+
+/* ---- PTP lock state string ---- */
+
+static const char *ptp_lock_str(aes67_ptp_lock_state_t state)
+{
+    switch (state) {
+    case AES67_PTP_LOCKED:   return "locked";
+    case AES67_PTP_LOCKING:  return "locking";
+    case AES67_PTP_UNLOCKED: return "unlocked";
+    default:                 return "unknown";
+    }
+}
+
+/* ---- GET / : serve gzipped SPA ---- */
+
+static esp_err_t handler_index(httpd_req_t *req)
+{
+    size_t len = index_html_gz_end - index_html_gz_start;
+    httpd_resp_set_type(req, "text/html");
+    httpd_resp_set_hdr(req, "Content-Encoding", "gzip");
+    httpd_resp_set_hdr(req, "Connection", "close");
+    return httpd_resp_send(req, (const char *)index_html_gz_start, len);
+}
+
+/* ---- GET /favicon.ico : empty response to prevent 404 socket waste ---- */
+
+static esp_err_t handler_favicon(httpd_req_t *req)
+{
+    httpd_resp_set_status(req, "204 No Content");
+    httpd_resp_set_hdr(req, "Connection", "close");
+    return httpd_resp_send(req, NULL, 0);
+}
+
+/* ---- GET /api/info ---- */
+
+static esp_err_t handler_api_info(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+    char *buf = malloc(JSON_BUF_SIZE);
+    if (!buf) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    aes67_config_t cfg;
+    aes67_node_get_config(ctx->node, &cfg);
+
+    /* Uptime in seconds */
+    int64_t uptime_us = esp_timer_get_time();
+    uint32_t uptime_s = (uint32_t)(uptime_us / 1000000);
+
+    /* Heap stats */
+    uint32_t heap_free = esp_get_free_heap_size();
+    uint32_t heap_min = esp_get_minimum_free_heap_size();
+
+    /* Network info */
+    uint32_t ip_u32 = 0;
+    uint8_t mac[6] = {0};
+    aes67_net_get_local_ip(&ip_u32);
+    aes67_net_get_local_mac(mac);
+    char ip_str[16];
+    aes67_net_u32_to_ip(ip_u32, ip_str, sizeof(ip_str));
+
+    int len = snprintf(buf, JSON_BUF_SIZE,
+        "{"
+        "\"name\":\"%s\","
+        "\"version\":\"%d.%d.%d\","
+        "\"ip\":\"%s\","
+        "\"mac\":\"%02x:%02x:%02x:%02x:%02x:%02x\","
+        "\"uptime\":%lu,"
+        "\"heap_free\":%lu,"
+        "\"heap_min\":%lu,"
+        "\"sample_rate\":%lu,"
+        "\"channels\":%u,"
+        "\"codec\":\"%s\","
+        "\"ptime_us\":%u"
+        "}",
+        cfg.node_name,
+        AES67_VERSION_MAJOR, AES67_VERSION_MINOR, AES67_VERSION_PATCH,
+        ip_str,
+        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
+        (unsigned long)uptime_s,
+        (unsigned long)heap_free,
+        (unsigned long)heap_min,
+        (unsigned long)cfg.audio.sample_rate,
+        cfg.audio.channels,
+        codec_name(cfg.audio.codec),
+        cfg.audio.packet_time_us);
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, buf, len);
+    free(buf);
+    return ESP_OK;
+}
+
+/* ---- GET /api/ptp ---- */
+
+static esp_err_t handler_api_ptp(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+    char *buf = malloc(JSON_BUF_SIZE);
+    if (!buf) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    aes67_ptp_handle_t ptp = NULL;
+    aes67_node_get_ptp(ctx->node, &ptp);
+
+    aes67_ptp_status_t st = {0};
+    if (ptp) {
+        aes67_ptp_get_status(ptp, &st);
+    }
+
+    /* Format grandmaster ID as dash-separated hex */
+    char gm_str[24];
+    snprintf(gm_str, sizeof(gm_str),
+             "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+             st.grandmaster_id[0], st.grandmaster_id[1],
+             st.grandmaster_id[2], st.grandmaster_id[3],
+             st.grandmaster_id[4], st.grandmaster_id[5],
+             st.grandmaster_id[6], st.grandmaster_id[7]);
+
+    int len = snprintf(buf, JSON_BUF_SIZE,
+        "{"
+        "\"lock_state\":\"%s\","
+        "\"grandmaster\":\"%s\","
+        "\"domain\":%u,"
+        "\"offset_ns\":%ld,"
+        "\"jitter_ns\":%ld,"
+        "\"path_delay_ns\":%ld"
+        "}",
+        ptp_lock_str(st.lock_state),
+        gm_str,
+        st.domain,
+        (long)st.offset_ns,
+        (long)st.jitter_ns,
+        (long)st.path_delay_ns);
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, buf, len);
+    free(buf);
+    return ESP_OK;
+}
+
+/* ---- GET /api/sources ---- */
+
+static esp_err_t handler_api_sources(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+    char *buf = malloc(JSON_BUF_SIZE);
+    if (!buf) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    aes67_session_handle_t sess = NULL;
+    aes67_node_get_session(ctx->node, &sess);
+
+    int pos = 0;
+    buf[pos++] = '[';
+
+    int count = sess ? aes67_session_get_source_count(sess) : 0;
+    bool first = true;
+
+    for (int i = 0; i < count && i < CONFIG_AES67_MAX_SOURCES; i++) {
+        aes67_source_t src;
+        if (aes67_session_get_source(sess, i, &src) != ESP_OK) {
+            continue;
+        }
+        if (!src.enabled) {
+            continue;
+        }
+
+        /* Get RTP stream stats if available */
+        aes67_rtp_stream_status_t rtp_st = {0};
+        if (src.rtp_stream) {
+            aes67_rtp_stream_get_status(src.rtp_stream, &rtp_st);
+        }
+
+        if (!first) {
+            buf[pos++] = ',';
+        }
+        first = false;
+
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+            "{"
+            "\"id\":%u,"
+            "\"name\":\"%s\","
+            "\"channels\":%u,"
+            "\"sample_rate\":%lu,"
+            "\"codec\":\"%s\","
+            "\"packets_sent\":%lu"
+            "}",
+            src.id, src.name,
+            src.rtp_config.channels,
+            (unsigned long)src.rtp_config.sample_rate,
+            codec_name(src.rtp_config.codec),
+            (unsigned long)rtp_st.packets_sent);
+
+        if (pos >= JSON_BUF_SIZE - 128) {
+            break;
+        }
+    }
+
+    buf[pos++] = ']';
+    buf[pos] = '\0';
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, buf, pos);
+    free(buf);
+    return ESP_OK;
+}
+
+/* ---- GET /api/sinks ---- */
+
+static esp_err_t handler_api_sinks(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+    char *buf = malloc(JSON_BUF_SIZE);
+    if (!buf) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    aes67_session_handle_t sess = NULL;
+    aes67_node_get_session(ctx->node, &sess);
+
+    int pos = 0;
+    buf[pos++] = '[';
+
+    int count = sess ? aes67_session_get_sink_count(sess) : 0;
+    bool first = true;
+
+    for (int i = 0; i < count && i < CONFIG_AES67_MAX_SINKS; i++) {
+        aes67_sink_t sink;
+        if (aes67_session_get_sink(sess, i, &sink) != ESP_OK) {
+            continue;
+        }
+        if (!sink.enabled) {
+            continue;
+        }
+
+        aes67_rtp_stream_status_t rtp_st = {0};
+        if (sink.rtp_stream) {
+            aes67_rtp_stream_get_status(sink.rtp_stream, &rtp_st);
+        }
+
+        if (!first) {
+            buf[pos++] = ',';
+        }
+        first = false;
+
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+            "{"
+            "\"id\":%u,"
+            "\"name\":\"%s\","
+            "\"channels\":%u,"
+            "\"sample_rate\":%lu,"
+            "\"codec\":\"%s\","
+            "\"packets_received\":%lu,"
+            "\"packets_lost\":%lu,"
+            "\"jitter_us\":%ld,"
+            "\"receiving\":%s"
+            "}",
+            sink.id, sink.name,
+            sink.rtp_config.channels,
+            (unsigned long)sink.rtp_config.sample_rate,
+            codec_name(sink.rtp_config.codec),
+            (unsigned long)rtp_st.packets_received,
+            (unsigned long)rtp_st.packets_lost,
+            (long)rtp_st.jitter_us,
+            (rtp_st.status_flags & AES67_RTP_STATUS_RECEIVING) ? "true" : "false");
+
+        if (pos >= JSON_BUF_SIZE - 128) {
+            break;
+        }
+    }
+
+    buf[pos++] = ']';
+    buf[pos] = '\0';
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, buf, pos);
+    free(buf);
+    return ESP_OK;
+}
+
+/* ---- GET /api/sap/remotes ---- */
+
+static esp_err_t handler_api_sap_remotes(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+    char *buf = malloc(JSON_BUF_SIZE);
+    if (!buf) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    aes67_sap_handle_t sap = NULL;
+    aes67_node_get_sap(ctx->node, &sap);
+
+    int pos = 0;
+    buf[pos++] = '[';
+
+    int count = sap ? aes67_sap_get_remote_count(sap) : 0;
+    bool first = true;
+
+    for (int i = 0; i < count; i++) {
+        aes67_sap_remote_source_t remote;
+        if (aes67_sap_get_remote(sap, i, &remote) != ESP_OK) {
+            continue;
+        }
+
+        if (!first) {
+            buf[pos++] = ',';
+        }
+        first = false;
+
+        char origin_str[16];
+        aes67_net_u32_to_ip(remote.origin_ip, origin_str, sizeof(origin_str));
+
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+            "{"
+            "\"name\":\"%s\","
+            "\"origin\":\"%s\","
+            "\"msg_id\":%u"
+            "}",
+            remote.name,
+            origin_str,
+            remote.msg_id);
+
+        if (pos >= JSON_BUF_SIZE - 128) {
+            break;
+        }
+    }
+
+    buf[pos++] = ']';
+    buf[pos] = '\0';
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, buf, pos);
+    free(buf);
+    return ESP_OK;
+}
+
+/* ---- POST /api/sinks : subscribe to remote source ---- */
+
+static esp_err_t handler_post_sink(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+
+    /* Read request body (max 2KB for SDP) */
+    int content_len = req->content_len;
+    if (content_len <= 0 || content_len > JSON_BUF_SIZE) {
+        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid body length");
+        return ESP_FAIL;
+    }
+
+    char *body = malloc(content_len + 1);
+    if (!body) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    int received = httpd_req_recv(req, body, content_len);
+    if (received != content_len) {
+        free(body);
+        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Incomplete body");
+        return ESP_FAIL;
+    }
+    body[content_len] = '\0';
+
+    /*
+     * Minimal JSON parsing: extract "name" and "sdp" fields.
+     * Expected format: {"name":"...","sdp":"v=0\r\n..."}
+     */
+    const char *name_key = "\"name\":\"";
+    const char *sdp_key = "\"sdp\":\"";
+
+    char name[64] = {0};
+    char *sdp = NULL;
+
+    /* Extract name */
+    char *p = strstr(body, name_key);
+    if (p) {
+        p += strlen(name_key);
+        char *end = strchr(p, '"');
+        if (end) {
+            size_t nlen = end - p;
+            if (nlen >= sizeof(name)) nlen = sizeof(name) - 1;
+            memcpy(name, p, nlen);
+            name[nlen] = '\0';
+        }
+    }
+
+    /* Extract SDP */
+    p = strstr(body, sdp_key);
+    if (p) {
+        p += strlen(sdp_key);
+        /* Find the closing quote (skip escaped quotes) */
+        char *end = p;
+        while (*end && !(*end == '"' && *(end - 1) != '\\')) {
+            end++;
+        }
+        if (*end == '"') {
+            size_t slen = end - p;
+            sdp = malloc(slen + 1);
+            if (sdp) {
+                memcpy(sdp, p, slen);
+                sdp[slen] = '\0';
+                /* Unescape \r\n sequences */
+                char *r = sdp, *w = sdp;
+                while (*r) {
+                    if (r[0] == '\\' && r[1] == 'r') {
+                        *w++ = '\r';
+                        r += 2;
+                    } else if (r[0] == '\\' && r[1] == 'n') {
+                        *w++ = '\n';
+                        r += 2;
+                    } else {
+                        *w++ = *r++;
+                    }
+                }
+                *w = '\0';
+            }
+        }
+    }
+
+    free(body);
+
+    /* If no SDP provided, try to find it from SAP remotes by name */
+    if (!sdp || !sdp[0]) {
+        free(sdp);
+        sdp = NULL;
+        aes67_sap_handle_t sap = NULL;
+        aes67_node_get_sap(ctx->node, &sap);
+        if (sap && name[0]) {
+            int rc = aes67_sap_get_remote_count(sap);
+            for (int i = 0; i < rc; i++) {
+                aes67_sap_remote_source_t remote;
+                if (aes67_sap_get_remote(sap, i, &remote) == ESP_OK &&
+                    strcmp(remote.name, name) == 0) {
+                    sdp = strdup(remote.sdp);
+                    break;
+                }
+            }
+        }
+    }
+
+    if (!name[0] || !sdp || !sdp[0]) {
+        free(sdp);
+        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
+                            "Missing 'name' or 'sdp' field, and name not found in SAP");
+        return ESP_FAIL;
+    }
+
+    /* Add the sink via session manager */
+    aes67_session_handle_t sess = NULL;
+    aes67_node_get_session(ctx->node, &sess);
+    if (!sess) {
+        free(sdp);
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    uint8_t id = 0;
+    esp_err_t ret = aes67_session_add_sink(sess, name, sdp, &id);
+    free(sdp);
+
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "add_sink failed: %s", esp_err_to_name(ret));
+        httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
+                            "Failed to add sink");
+        return ESP_FAIL;
+    }
+
+    /* Respond with the assigned ID */
+    char resp[32];
+    int len = snprintf(resp, sizeof(resp), "{\"id\":%u}", id);
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, resp, len);
+
+    ESP_LOGI(TAG, "Sink '%s' added with id=%u", name, id);
+    return ESP_OK;
+}
+
+/* ---- DELETE /api/sinks/N : remove sink by ID ---- */
+
+static esp_err_t handler_delete_sink(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+
+    /* Parse sink ID from URI: /api/sinks/<id> */
+    const char *uri = req->uri;
+    const char *id_str = strrchr(uri, '/');
+    if (!id_str || !*(id_str + 1)) {
+        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing sink ID");
+        return ESP_FAIL;
+    }
+    id_str++; /* skip the '/' */
+
+    int id = atoi(id_str);
+    if (id < 0 || id > 255) {
+        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid sink ID");
+        return ESP_FAIL;
+    }
+
+    aes67_session_handle_t sess = NULL;
+    aes67_node_get_session(ctx->node, &sess);
+    if (!sess) {
+        httpd_resp_send_500(req);
+        return ESP_FAIL;
+    }
+
+    esp_err_t ret = aes67_session_remove_sink(sess, (uint8_t)id);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "remove_sink(%d) failed: %s", id, esp_err_to_name(ret));
+        httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Sink not found");
+        return ESP_FAIL;
+    }
+
+    httpd_resp_set_type(req, "application/json");
+    httpd_resp_send(req, "{\"ok\":true}", 11);
+
+    ESP_LOGI(TAG, "Sink id=%d removed", id);
+    return ESP_OK;
+}
+
+/* ---- WebSocket handler ---- */
+
+static esp_err_t handler_ws(httpd_req_t *req)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)req->user_ctx;
+
+    if (req->method == HTTP_GET) {
+        /* WebSocket upgrade -- store the fd */
+        ctx->ws_fd = httpd_req_to_sockfd(req);
+        ESP_LOGI(TAG, "WebSocket connected (fd=%d)", ctx->ws_fd);
+        return ESP_OK;
+    }
+
+    /* Handle incoming WS frames (e.g. pings, close) */
+    httpd_ws_frame_t ws_pkt;
+    memset(&ws_pkt, 0, sizeof(ws_pkt));
+    ws_pkt.type = HTTPD_WS_TYPE_TEXT;
+
+    /* Read the frame to acknowledge it */
+    uint8_t frame_buf[128];
+    ws_pkt.payload = frame_buf;
+    esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, sizeof(frame_buf));
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "WS recv failed: %s", esp_err_to_name(ret));
+        ctx->ws_fd = -1;
+    }
+
+    return ESP_OK;
+}
+
+/* ---- WebSocket 2Hz push task ---- */
+
+/*
+ * Build a compact JSON status payload and push it to the connected
+ * WebSocket client. Runs every 500ms on core 1, priority 3.
+ */
+static void ws_push_task(void *arg)
+{
+    struct aes67_webui *ctx = (struct aes67_webui *)arg;
+    char *buf = malloc(JSON_BUF_SIZE);
+
+    if (!buf) {
+        ESP_LOGW(TAG, "WS push task: alloc failed");
+        vTaskDelete(NULL);
+        return;
+    }
+
+    while (ctx->running) {
+        vTaskDelay(pdMS_TO_TICKS(500));
+
+        /* Skip if no WebSocket client connected */
+        if (ctx->ws_fd < 0 || !ctx->httpd) {
+            continue;
+        }
+
+        /* Gather status data */
+        int64_t uptime_us = esp_timer_get_time();
+        uint32_t uptime_s = (uint32_t)(uptime_us / 1000000);
+        uint32_t heap_free = esp_get_free_heap_size();
+
+        /* PTP status */
+        aes67_ptp_handle_t ptp = NULL;
+        aes67_node_get_ptp(ctx->node, &ptp);
+        aes67_ptp_status_t ptp_st = {0};
+        if (ptp) {
+            aes67_ptp_get_status(ptp, &ptp_st);
+        }
+
+        char gm_str[24];
+        snprintf(gm_str, sizeof(gm_str),
+                 "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
+                 ptp_st.grandmaster_id[0], ptp_st.grandmaster_id[1],
+                 ptp_st.grandmaster_id[2], ptp_st.grandmaster_id[3],
+                 ptp_st.grandmaster_id[4], ptp_st.grandmaster_id[5],
+                 ptp_st.grandmaster_id[6], ptp_st.grandmaster_id[7]);
+
+        /* We are GM if locked with zero offset and zero path delay
+         * (slave always has non-zero offset/delay from the master) */
+        bool is_gm = (ptp_st.lock_state == AES67_PTP_LOCKED &&
+                       ptp_st.offset_ns == 0 && ptp_st.path_delay_ns == 0);
+
+        /* Start building JSON */
+        int pos = snprintf(buf, JSON_BUF_SIZE,
+            "{"
+            "\"t\":\"status\","
+            "\"uptime\":%lu,"
+            "\"heap_free\":%lu,"
+            "\"ptp\":{\"lock\":\"%s\",\"gm\":\"%s\",\"is_gm\":%s,\"offset\":%ld,\"jitter\":%ld,\"delay\":%ld},",
+            (unsigned long)uptime_s,
+            (unsigned long)heap_free,
+            ptp_lock_str(ptp_st.lock_state),
+            gm_str,
+            is_gm ? "true" : "false",
+            (long)ptp_st.offset_ns,
+            (long)ptp_st.jitter_ns,
+            (long)ptp_st.path_delay_ns);
+
+        /* Source stats */
+        aes67_session_handle_t sess = NULL;
+        aes67_node_get_session(ctx->node, &sess);
+
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "\"sources\":[");
+        if (sess) {
+            bool first = true;
+            int src_count = aes67_session_get_source_count(sess);
+            for (int i = 0; i < src_count && i < CONFIG_AES67_MAX_SOURCES; i++) {
+                aes67_source_t src;
+                if (aes67_session_get_source(sess, i, &src) != ESP_OK || !src.enabled) {
+                    continue;
+                }
+                aes67_rtp_stream_status_t rtp_st = {0};
+                if (src.rtp_stream) {
+                    aes67_rtp_stream_get_status(src.rtp_stream, &rtp_st);
+                }
+                if (!first) buf[pos++] = ',';
+                first = false;
+                const char *codec = aes67_codec_to_str(src.rtp_config.codec);
+                pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+                    "{\"id\":%u,\"name\":\"%s\",\"codec\":\"%s\",\"rate\":%lu,"
+                    "\"ch\":%u,\"ptime\":%u,\"tx\":%lu}",
+                    src.id, src.name, codec,
+                    (unsigned long)src.rtp_config.sample_rate,
+                    src.rtp_config.channels,
+                    src.rtp_config.packet_time_us,
+                    (unsigned long)rtp_st.packets_sent);
+                if (pos >= JSON_BUF_SIZE - 256) break;
+            }
+        }
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "],");
+
+        /* Sink stats */
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "\"sinks\":[");
+        if (sess) {
+            bool first = true;
+            int sink_count = aes67_session_get_sink_count(sess);
+            for (int i = 0; i < sink_count && i < CONFIG_AES67_MAX_SINKS; i++) {
+                aes67_sink_t sink;
+                if (aes67_session_get_sink(sess, i, &sink) != ESP_OK || !sink.enabled) {
+                    continue;
+                }
+                aes67_rtp_stream_status_t rtp_st = {0};
+                if (sink.rtp_stream) {
+                    aes67_rtp_stream_get_status(sink.rtp_stream, &rtp_st);
+                }
+                if (!first) buf[pos++] = ',';
+                first = false;
+                /* Use Ethernet hook counters for sink stats since the hook
+                 * intercepts RTP at L2 before the RTP engine sees packets */
+                extern uint32_t s_hook_pkt_count;
+                extern uint32_t s_hook_seq_lost;
+                const char *scodec = aes67_codec_to_str(sink.rtp_config.codec);
+                bool active = s_hook_pkt_count > 0;
+                pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+                    "{\"id\":%u,\"name\":\"%s\",\"codec\":\"%s\",\"rate\":%lu,"
+                    "\"ch\":%u,\"ptime\":%u,\"rx\":%lu,\"lost\":%lu,"
+                    "\"jitter\":%ld,\"active\":%s}",
+                    sink.id, sink.name, scodec,
+                    (unsigned long)sink.rtp_config.sample_rate,
+                    sink.rtp_config.channels,
+                    sink.rtp_config.packet_time_us,
+                    (unsigned long)s_hook_pkt_count,
+                    (unsigned long)s_hook_seq_lost,
+                    (long)rtp_st.jitter_us,
+                    active ? "true" : "false");
+                if (pos >= JSON_BUF_SIZE - 256) break;
+            }
+        }
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "],");
+
+        /* SAP remotes */
+        aes67_sap_handle_t sap = NULL;
+        aes67_node_get_sap(ctx->node, &sap);
+
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "\"sap_remotes\":[");
+        if (sap) {
+            bool first = true;
+            int rcount = aes67_sap_get_remote_count(sap);
+            for (int i = 0; i < rcount; i++) {
+                aes67_sap_remote_source_t remote;
+                if (aes67_sap_get_remote(sap, i, &remote) != ESP_OK) {
+                    continue;
+                }
+                char origin_str[16];
+                aes67_net_u32_to_ip(remote.origin_ip, origin_str, sizeof(origin_str));
+                if (!first) buf[pos++] = ',';
+                first = false;
+                /* Parse SDP to extract stream details */
+                aes67_sdp_t sdp = {0};
+                const char *codec_str = "?";
+                uint32_t rate = 0;
+                uint8_t ch = 0;
+                uint16_t ptime = 0;
+                if (aes67_sdp_parse(remote.sdp, &sdp) == ESP_OK) {
+                    codec_str = aes67_codec_to_str(sdp.codec);
+                    rate = sdp.sample_rate;
+                    ch = sdp.channels;
+                    ptime = sdp.ptime_us;
+                }
+                pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+                    "{\"name\":\"%s\",\"origin\":\"%s\",\"codec\":\"%s\","
+                    "\"rate\":%lu,\"ch\":%u,\"ptime\":%u}",
+                    remote.name, origin_str, codec_str,
+                    (unsigned long)rate, ch, ptime);
+                if (pos >= JSON_BUF_SIZE - 128) break;
+            }
+        }
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos, "]");
+
+        /* Ethernet hook stats (defined in main.c) */
+        extern uint32_t s_hook_pkt_count;
+        extern uint32_t s_hook_seq_lost;
+        extern uint32_t s_hook_total_frames;
+        pos += snprintf(buf + pos, JSON_BUF_SIZE - pos,
+            ",\"eth\":{\"rtp\":%lu,\"total\":%lu,\"lost\":%lu}",
+            (unsigned long)s_hook_pkt_count,
+            (unsigned long)s_hook_total_frames,
+            (unsigned long)s_hook_seq_lost);
+
+        /* Close the JSON object */
+        buf[pos++] = '}';
+        buf[pos] = '\0';
+
+        /* Send via async WS frame (must be queued through httpd work queue) */
+        httpd_ws_frame_t ws_pkt;
+        memset(&ws_pkt, 0, sizeof(ws_pkt));
+        ws_pkt.type = HTTPD_WS_TYPE_TEXT;
+        ws_pkt.payload = (uint8_t *)buf;
+        ws_pkt.len = pos;
+
+        esp_err_t ret = httpd_ws_send_frame_async(ctx->httpd, ctx->ws_fd, &ws_pkt);
+        if (ret != ESP_OK) {
+            ESP_LOGW(TAG, "WS send failed (fd=%d): %s", ctx->ws_fd,
+                     esp_err_to_name(ret));
+            ctx->ws_fd = -1;
+        }
+    }
+
+    free(buf);
+    ESP_LOGI(TAG, "WS push task stopped");
+    vTaskDelete(NULL);
+}
+
+/* ---- Public API ---- */
+
+esp_err_t aes67_webui_init(aes67_node_handle_t node, uint16_t port,
+                            aes67_webui_handle_t *handle)
+{
+    if (!node || !handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct aes67_webui *ctx = calloc(1, sizeof(struct aes67_webui));
+    if (!ctx) {
+        ESP_LOGW(TAG, "Failed to allocate webui context");
+        return ESP_ERR_NO_MEM;
+    }
+
+    ctx->node = node;
+    ctx->port = port;
+    ctx->ws_fd = -1;
+    ctx->ws_task = NULL;
+    ctx->httpd = NULL;
+    ctx->running = false;
+
+    *handle = ctx;
+    return ESP_OK;
+}
+
+esp_err_t aes67_webui_start(aes67_webui_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (handle->running) {
+        ESP_LOGW(TAG, "Web UI already running");
+        return ESP_OK;
+    }
+
+    /* Configure and start the HTTP server */
+    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
+    config.server_port = handle->port;
+    config.max_uri_handlers = 16;
+    config.stack_size = 8192;
+    config.lru_purge_enable = true;
+    config.max_open_sockets = 7;
+    config.keep_alive_enable = false;  /* Close HTTP conns immediately, free sockets for WS */
+    config.core_id = 1;  /* Run HTTP server on core 1 (audio on core 0) */
+
+    esp_err_t ret = httpd_start(&handle->httpd, &config);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "httpd_start failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    /* Register URI handlers -- all use the webui context as user_ctx */
+
+    /* Serve the SPA */
+    const httpd_uri_t uri_index = {
+        .uri = "/",
+        .method = HTTP_GET,
+        .handler = handler_index,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_index);
+
+    /* Favicon -- prevent browser 404 and socket waste */
+    const httpd_uri_t uri_favicon = {
+        .uri = "/favicon.ico",
+        .method = HTTP_GET,
+        .handler = handler_favicon,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_favicon);
+
+    /* WebSocket endpoint */
+    const httpd_uri_t uri_ws = {
+        .uri = "/ws",
+        .method = HTTP_GET,
+        .handler = handler_ws,
+        .user_ctx = handle,
+        .is_websocket = true,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_ws);
+
+    /* REST: device info */
+    const httpd_uri_t uri_info = {
+        .uri = "/api/info",
+        .method = HTTP_GET,
+        .handler = handler_api_info,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_info);
+
+    /* REST: PTP status */
+    const httpd_uri_t uri_ptp = {
+        .uri = "/api/ptp",
+        .method = HTTP_GET,
+        .handler = handler_api_ptp,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_ptp);
+
+    /* REST: source streams */
+    const httpd_uri_t uri_sources = {
+        .uri = "/api/sources",
+        .method = HTTP_GET,
+        .handler = handler_api_sources,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_sources);
+
+    /* REST: sink streams (GET) */
+    const httpd_uri_t uri_sinks_get = {
+        .uri = "/api/sinks",
+        .method = HTTP_GET,
+        .handler = handler_api_sinks,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_sinks_get);
+
+    /* REST: SAP discovered remotes */
+    const httpd_uri_t uri_sap_remotes = {
+        .uri = "/api/sap/remotes",
+        .method = HTTP_GET,
+        .handler = handler_api_sap_remotes,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_sap_remotes);
+
+    /* REST: add sink (POST) */
+    const httpd_uri_t uri_sinks_post = {
+        .uri = "/api/sinks",
+        .method = HTTP_POST,
+        .handler = handler_post_sink,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_sinks_post);
+
+    /* REST: remove sink (DELETE /api/sinks/N) */
+    const httpd_uri_t uri_sinks_delete = {
+        .uri = "/api/sinks/*",
+        .method = HTTP_DELETE,
+        .handler = handler_delete_sink,
+        .user_ctx = handle,
+    };
+    httpd_register_uri_handler(handle->httpd, &uri_sinks_delete);
+
+    /* Start the 2Hz WebSocket push task on core 1 */
+    handle->running = true;
+    BaseType_t xret = xTaskCreatePinnedToCore(
+        ws_push_task, "aes67_ws", 4096, handle,
+        3, &handle->ws_task, 1);
+    if (xret != pdPASS) {
+        ESP_LOGW(TAG, "Failed to create WS push task");
+        handle->ws_task = NULL;
+    }
+
+    ESP_LOGI(TAG, "Web UI started on port %u", handle->port);
+    return ESP_OK;
+}
+
+esp_err_t aes67_webui_stop(aes67_webui_handle_t handle)
+{
+    if (!handle) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (!handle->running) {
+        return ESP_OK;
+    }
+
+    /* Signal the push task to exit */
+    handle->running = false;
+
+    /* Give it time to exit cleanly */
+    if (handle->ws_task) {
+        vTaskDelay(pdMS_TO_TICKS(600));
+        handle->ws_task = NULL;
+    }
+
+    /* Stop the HTTP server */
+    if (handle->httpd) {
+        httpd_stop(handle->httpd);
+        handle->httpd = NULL;
+    }
+
+    handle->ws_fd = -1;
+
+    ESP_LOGI(TAG, "Web UI stopped");
+    return ESP_OK;
+}

+ 3 - 0
components/aes67/third_party/esp_eth_time/README.md

@@ -0,0 +1,3 @@
+# ESP Ethernet Time Control Component Example
+
+This example component provides a wrapper around management of the internal Ethernet MAC Time (Time Stamping) system which is normally accessed via `esp_eth_ioctl` commands. The component is offering a more intuitive API mimicking POSIX `clock_settime`, `clock_gettime` group of time functions and so making it easier to integrate with existing systems.

+ 142 - 0
components/aes67/third_party/esp_eth_time/esp_eth_time.c

@@ -0,0 +1,142 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+#include <errno.h>
+#include "esp_eth_time.h"
+
+static esp_eth_handle_t s_eth_hndl;
+
+static int esp_eth_clock_esp_err_to_errno(esp_err_t esp_err)
+{
+    switch (esp_err)
+    {
+    case ESP_ERR_INVALID_ARG:
+        return EINVAL;
+    case ESP_ERR_INVALID_STATE:
+        return EBUSY;
+    case ESP_ERR_TIMEOUT:
+        return ETIME;
+    }
+    // default "no err" when error cannot be isolated
+    return 0;
+}
+
+int esp_eth_clock_adjtime(clockid_t clk_id, esp_eth_clock_adj_param_t *adj)
+{
+    switch (clk_id) {
+    case CLOCK_PTP_SYSTEM:
+        if (adj->mode == ETH_CLK_ADJ_FREQ_SCALE) {
+            esp_err_t ret = esp_eth_ioctl(s_eth_hndl, ETH_MAC_ESP_CMD_ADJ_PTP_FREQ, &adj->freq_scale);
+            if (ret != ESP_OK) {
+                errno = esp_eth_clock_esp_err_to_errno(ret);
+                return -1;
+            }
+        } else {
+            errno = EINVAL;
+            return -1;
+        }
+        break;
+    default:
+        errno = EINVAL;
+        return -1;
+    }
+    return 0;
+}
+
+int esp_eth_clock_settime(clockid_t clock_id, const struct timespec *tp)
+{
+    switch (clock_id) {
+    case CLOCK_PTP_SYSTEM: {
+        if (s_eth_hndl) {
+            eth_mac_time_t ptp_time = {
+                .seconds = tp->tv_sec,
+                .nanoseconds = tp->tv_nsec
+            };
+            esp_err_t ret = esp_eth_ioctl(s_eth_hndl, ETH_MAC_ESP_CMD_S_PTP_TIME, &ptp_time);
+            if (ret != ESP_OK) {
+                errno = esp_eth_clock_esp_err_to_errno(ret);
+                return -1;
+            }
+        } else {
+            errno = ENODEV;
+            return -1;
+        }
+        break;
+    }
+    default:
+        errno = EINVAL;
+        return -1;
+    }
+    return 0;
+}
+
+int esp_eth_clock_gettime(clockid_t clock_id, struct timespec *tp)
+{
+    switch (clock_id) {
+    case CLOCK_PTP_SYSTEM: {
+        if (s_eth_hndl) {
+            eth_mac_time_t ptp_time;
+            esp_err_t ret = esp_eth_ioctl(s_eth_hndl, ETH_MAC_ESP_CMD_G_PTP_TIME, &ptp_time);
+            if (ret != ESP_OK) {
+                errno = esp_eth_clock_esp_err_to_errno(ret);
+                return -1;
+            }
+            tp->tv_sec = ptp_time.seconds;
+            tp->tv_nsec = ptp_time.nanoseconds;
+        } else {
+            errno = ENODEV;
+            return -1;
+        }
+        break;
+    }
+    default:
+        errno = EINVAL;
+        return -1;
+    }
+    return 0;
+}
+
+int esp_eth_clock_set_target_time(clockid_t clock_id, struct timespec *tp)
+{
+    eth_mac_time_t mac_target_time = {
+        .seconds = tp->tv_sec,
+        .nanoseconds = tp->tv_nsec
+    };
+    esp_err_t ret = esp_eth_ioctl(s_eth_hndl, ETH_MAC_ESP_CMD_S_TARGET_TIME, &mac_target_time);
+    if (ret != ESP_OK) {
+        errno = esp_eth_clock_esp_err_to_errno(ret);
+        return -1;
+    }
+    return 0;
+}
+
+int esp_eth_clock_register_target_cb(clockid_t clock_id,
+                                     ts_target_exceed_cb_from_isr_t ts_callback)
+{
+    esp_err_t ret = esp_eth_ioctl(s_eth_hndl, ETH_MAC_ESP_CMD_S_TARGET_CB, ts_callback);
+    if (ret != ESP_OK) {
+        errno = esp_eth_clock_esp_err_to_errno(ret);
+        return -1;
+    }
+    return 0;
+}
+
+esp_err_t esp_eth_clock_init(clockid_t clock_id, esp_eth_clock_cfg_t *cfg)
+{
+    switch (clock_id) {
+    case CLOCK_PTP_SYSTEM:
+        // PTP Clock is part of Ethernet system
+        bool ptp_enable = true;
+        if (esp_eth_ioctl(cfg->eth_hndl, ETH_MAC_ESP_CMD_PTP_ENABLE, &ptp_enable) != ESP_OK) {
+            return ESP_FAIL;
+        }
+        s_eth_hndl = cfg->eth_hndl;
+        break;
+    default:
+        return ESP_FAIL;
+    }
+    return ESP_OK;
+}

+ 131 - 0
components/aes67/third_party/esp_eth_time/esp_eth_time.h

@@ -0,0 +1,131 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+#pragma once
+
+#include <sys/time.h>
+#include <stdint.h>
+#include "esp_err.h"
+#include "esp_eth_driver.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define CLOCK_PTP_SYSTEM         ((clockid_t) 19)
+
+/**
+ * @brief Configuration of clock during initialization
+ *
+ */
+typedef struct {
+    esp_eth_handle_t eth_hndl;
+} esp_eth_clock_cfg_t;
+
+/**
+ * @brief The mode of clock adjustment.
+ *
+ */
+typedef enum {
+    ETH_CLK_ADJ_FREQ_SCALE,
+} esp_eth_clock_adj_mode_t;
+
+/**
+ * @brief Structure containing parameters for adjusting the Ethernet clock.
+ *
+ */
+typedef struct {
+    /**
+     * @brief The mode of clock adjustment.
+     *
+     */
+    esp_eth_clock_adj_mode_t mode;
+
+    /**
+     * @brief The frequency scale factor when in ETH_CLK_ADJ_FREQ_SCALE mode.
+     *
+     * This value represents the ratio of the desired frequency to the actual
+     * frequency. A value greater than 1 increases the frequency, while a value
+     * less than 1 decreases the frequency.
+     */
+    double freq_scale;
+} esp_eth_clock_adj_param_t;
+
+/**
+ * @brief Adjust the system clock frequency
+ *
+ * @param clk_id Identifier of the clock to adjust
+ * @param buf Pointer to the adjustment parameters
+ *
+ * @return
+ *     - 0: Success
+ *     - -1: Failure
+ */
+int esp_eth_clock_adjtime(clockid_t clk_id, esp_eth_clock_adj_param_t *adj);
+
+/**
+ * @brief Set the system clock time
+ *
+ * @param clk_id Identifier of the clock to set
+ * @param tp Pointer to the new time
+ *
+ * @return
+ *     - 0: Success
+ *     - -1: Failure
+ */
+int esp_eth_clock_settime(clockid_t clock_id, const struct timespec *tp);
+
+/**
+ * @brief Get the current system clock time
+ *
+ * @param clk_id Identifier of the clock to query
+ * @param tp Pointer to the buffer to store the current time
+ *
+ * @return
+ *     - 0: Success
+ *     - -1: Failure
+ */
+int esp_eth_clock_gettime(clockid_t clock_id, struct timespec *tp);
+
+/**
+ * @brief Set the target time for the system clock.
+ *
+ * @param clk_id Identifier of the clock to set the target time for
+ * @param tp Pointer to the target time
+ *
+ * @return
+ *     - 0: Success
+ *     - -1: Failure
+ */
+int esp_eth_clock_set_target_time(clockid_t clock_id, struct timespec *tp);
+
+/**
+ * @brief Register callback function invoked on Time Stamp target time exceeded interrupt
+ *
+ * @param clock_id Identifier of the clock
+ * @param ts_callback callback function to be registered
+ * @return
+ *     - 0: Success
+ *     - -1: Failure
+ */
+int esp_eth_clock_register_target_cb(clockid_t clock_id,
+                                     ts_target_exceed_cb_from_isr_t ts_callback);
+
+/**
+ * @brief Initialize the Ethernet clock subsystem
+ *
+ * @param clk_id Identifier of the clock to initialize
+ * @param cfg Pointer to the configuration structure
+ *
+ * @return
+ *     - ESP_OK: Success
+ *     - ESP_FAIL: Failure
+ */
+esp_err_t esp_eth_clock_init(clockid_t clock_id, esp_eth_clock_cfg_t *cfg);
+
+#ifdef __cplusplus
+}
+#endif

+ 3 - 0
components/aes67/third_party/ptpd/README.md

@@ -0,0 +1,3 @@
+# NuttX PTP Daemon Port Example
+
+This example component presents port of [Nuttx PTP Daemon](https://github.com/apache/nuttx-apps/tree/master/netutils/ptpd) for ESP-IDF.

+ 188 - 0
components/aes67/third_party/ptpd/include/ptpd.h

@@ -0,0 +1,188 @@
+/*
+ * SPDX-FileCopyrightText: 2020-2024 The Apache Software Foundation
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * SPDX-FileContributor: 2024 Espressif Systems (Shanghai) CO LTD
+ */
+
+/****************************************************************************
+ * apps/include/netutils/ptpd.h
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The
+ * ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the
+ * License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ ****************************************************************************/
+
+#ifndef __APPS_INCLUDE_NETUTILS_PTPD_H
+#define __APPS_INCLUDE_NETUTILS_PTPD_H
+
+// ESP_PTP
+#include <time.h>
+#ifndef FAR
+#define FAR
+#endif
+
+/****************************************************************************
+ * Included Files
+ ****************************************************************************/
+
+/****************************************************************************
+ * Pre-processor Definitions
+ ****************************************************************************/
+
+/****************************************************************************
+ * Public Types
+ ****************************************************************************/
+
+/* PTPD status information structure */
+
+struct ptpd_status_s
+{
+  /* Is there a valid remote clock source active? */
+
+  bool clock_source_valid;
+
+  /* Information about selected best clock source */
+
+  struct
+  {
+    uint8_t id[8];     /* Clock identity */
+    int utcoffset;     /* Offset between clock time and UTC time (seconds) */
+    int priority1;     /* Main priority field */
+    int clockclass;    /* Clock class (IEEE-1588, lower is better) */
+    int accuracy;      /* Clock accuracy (IEEE-1588, lower is better) */
+    int variance;      /* Clock variance (IEEE-1588, lower is better) */
+    int priority2;     /* Secondary priority field */
+    uint8_t gm_id[8];  /* Grandmaster clock identity */
+    int stepsremoved;  /* How many steps from grandmaster clock */
+    int timesource;    /* Type of time source (IEEE-1588) */
+  } clock_source_info;
+
+  /* When was clock last updated or adjusted (CLOCK_REALTIME).
+   * Matches last_received_sync but in different clock.
+   */
+
+  struct timespec last_clock_update;
+
+  /* Details of clock adjustment made at last_clock_update */
+
+  int64_t last_delta_ns;     /* Latest measured clock error */
+  int64_t last_adjtime_ns;   /* Previously applied adjtime() offset */
+
+  /* Averaged clock drift estimate (parts per billion).
+   * Positive means remote clock runs faster than local clock before
+   * adjustment.
+   */
+
+  long drift_ppb;
+
+  /* Averaged path delay */
+
+  long path_delay_ns;
+
+  /* Timestamps of latest received packets (CLOCK_MONOTONIC) */
+
+  struct timespec last_received_multicast; /* Any multicast packet */
+  struct timespec last_received_announce;  /* Announce from any server */
+  struct timespec last_received_sync;      /* Sync from selected source */
+
+  /* Timestamps of latest transmitted packets (CLOCK_MONOTONIC) */
+
+  struct timespec last_transmitted_sync;
+  struct timespec last_transmitted_announce;
+  struct timespec last_transmitted_delayresp;
+  struct timespec last_transmitted_delayreq;
+};
+
+/****************************************************************************
+ * Public Data
+ ****************************************************************************/
+
+#ifdef __cplusplus
+#define EXTERN extern "C"
+extern "C"
+{
+#else
+#define EXTERN extern
+#endif
+
+/****************************************************************************
+ * Public Function Prototypes
+ ****************************************************************************/
+
+/****************************************************************************
+ * Name: ptpd_start
+ *
+ * Description:
+ *   Start the PTP daemon and bind it to specified interface.
+ *
+ * Input Parameters:
+ *   interface - Name of the network interface to bind to, e.g. "eth0"
+ *
+ * Returned Value:
+ *   On success, the non-negative task ID of the PTP daemon is returned;
+ *   On failure, a negated errno value is returned.
+ *
+ ****************************************************************************/
+
+int ptpd_start(FAR const char *interface);
+
+/****************************************************************************
+ * Name: ptpd_status
+ *
+ * Description:
+ *   Query status from a running PTP daemon.
+ *
+ * Input Parameters:
+ *   pid     - Process ID previously returned by ptpd_start()
+ *   status  - Pointer to storage for status information.
+ *
+ * Returned Value:
+ *   On success, returns OK.
+ *   On failure, a negated errno value is returned.
+ *
+ * Assumptions/Limitations:
+ *   Multiple threads with priority less than CONFIG_NETUTILS_PTPD_SERVERPRIO
+ *   can request status simultaneously. If higher priority threads request
+ *   status simultaneously, some of the requests may timeout.
+ *
+ ****************************************************************************/
+
+int ptpd_status(int pid, FAR struct ptpd_status_s *status);
+
+/****************************************************************************
+ * Name: ptpd_stop
+ *
+ * Description:
+ *   Stop PTP daemon
+ *
+ * Input Parameters:
+ *   pid     - Process ID previously returned by ptpd_start()
+ *
+ * Returned Value:
+ *   On success, returns OK.
+ *   On failure, a negated errno value is returned.
+ *
+ ****************************************************************************/
+
+int ptpd_stop(int pid);
+
+#undef EXTERN
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* __APPS_INCLUDE_NETUTILS_PTPD_H */

+ 2437 - 0
components/aes67/third_party/ptpd/ptpd.c

@@ -0,0 +1,2437 @@
+/*
+ * SPDX-FileCopyrightText: 2020-2024 The Apache Software Foundation
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * SPDX-FileContributor: 2024-2025 Espressif Systems (Shanghai) CO LTD
+ */
+
+/****************************************************************************
+ * apps/netutils/ptpd/ptpd.c
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The
+ * ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the
+ * License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ ****************************************************************************/
+
+#define ESP_PTP 1
+
+/****************************************************************************
+ * Included Files
+ ****************************************************************************/
+#ifndef ESP_PTP
+#include <nuttx/config.h>
+#endif
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include <sys/socket.h>
+#include <sys/time.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <sched.h>
+#include <assert.h>
+#include <errno.h>
+#ifndef ESP_PTP
+#include <debug.h>
+#endif
+#include <unistd.h>
+#include <fcntl.h>
+
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#ifndef ESP_PTP
+#include <netutils/ipmsfilter.h>
+#endif
+
+#include <net/if.h>
+#include <sys/ioctl.h>
+#include <sys/poll.h>
+#ifndef ESP_PTP
+#include <nuttx/net/netconfig.h>
+#include <netutils/ptpd.h>
+#endif
+
+#include "ptpv2.h"
+
+#ifdef ESP_PTP
+#include "ptpd.h"
+#include "esp_netif.h"
+#include "esp_eth_mac_esp.h"
+#include "esp_eth_driver.h"
+#include "esp_vfs_l2tap.h"
+#include "semaphore.h"
+#include "esp_log.h"
+#include "esp_err.h"
+#include "lwip/prot/ethernet.h" // Ethernet headers
+
+#include "esp_eth_time.h"
+
+#define ETH_TYPE_PTP 0x88F7
+
+#define SET_MAC_ADDR(addr, a, b, c, d, e, f) do { \
+    addr[0] = a; addr[1] = b; addr[2] = c; \
+    addr[3] = d; addr[4] = e; addr[5] = f; \
+} while(0)
+
+#define ERROR ESP_FAIL
+#define OK ESP_OK
+
+#define UNUSED (void)
+
+#define MSEC_PER_SEC 1000
+#define NSEC_PER_USEC 1000
+#define NSEC_PER_MSEC 1000000ll
+#define NSEC_PER_SEC 1000000000ll
+
+// To able to set either only server or only client
+#ifndef CONFIG_NETUTILS_PTPD_TIMEOUT_MS
+#define CONFIG_NETUTILS_PTPD_TIMEOUT_MS 0
+#endif
+#ifndef CONFIG_NETUTILS_PTPD_SETTIME_THRESHOLD_MS
+#define CONFIG_NETUTILS_PTPD_SETTIME_THRESHOLD_MS 0
+#endif
+#ifndef CONFIG_NETUTILS_PTPD_MAX_PATH_DELAY_NS
+#define CONFIG_NETUTILS_PTPD_MAX_PATH_DELAY_NS 0
+#endif
+#ifndef CONFIG_NETUTILS_PTPD_DELAYREQ_AVGCOUNT
+#define CONFIG_NETUTILS_PTPD_DELAYREQ_AVGCOUNT 0
+#endif
+#ifndef CONFIG_NETUTILS_PTPD_DELAYRESP_INTERVAL
+#define CONFIG_NETUTILS_PTPD_DELAYRESP_INTERVAL 0
+#endif
+#ifndef CONFIG_NETUTILS_PTPD_PATH_DELAY_STABILITY_NS
+#define CONFIG_NETUTILS_PTPD_PATH_DELAY_STABILITY_NS 0
+#endif
+
+#define clock_timespec_subtract(ts1, ts2, ts3) timespecsub(ts1, ts2, ts3)
+#define clock_timespec_add(ts1, ts2, ts3) timespecadd(ts1, ts2, ts3)
+
+#endif // ESP_PTP
+
+/****************************************************************************
+ * Private Data
+ ****************************************************************************/
+
+#ifdef ESP_PTP
+#define ADJ_FREQ_MAX 512000 // TODO tuneup
+typedef struct
+{
+  int32_t kp;
+  int32_t ki;
+  int32_t drift_acc;
+} pi_cntrl_t;
+#endif // ESP_PTP
+
+/* Carrier structure for querying PTPD status */
+
+struct ptpd_statusreq_s
+{
+  FAR sem_t *done;
+  FAR struct ptpd_status_s *dest;
+};
+
+/* Main PTPD state storage */
+
+struct ptp_state_s
+{
+  /* Request for PTPD task to stop or report status */
+
+  bool stop;
+  struct ptpd_statusreq_s status_req;
+
+#ifdef ESP_PTP
+  uint8_t intf_hw_addr[ETH_ADDR_LEN];
+  int ptp_socket;
+  esp_eth_handle_t cached_eth_handle;  /* Cached for freq adj calls */
+
+  int64_t remote_time_ns_prev;
+  int64_t local_time_ns_prev;
+
+  int64_t last_offset_ns;
+
+  pi_cntrl_t offset_pi;
+#endif // ESP_PTP
+
+  /* Address of network interface we are operating on */
+
+  struct sockaddr_in interface_addr;
+
+  /* Socket bound to interface for transmission (UDP) */
+
+  int tx_socket;
+
+  /* Sockets for PTP event and information ports (UDP) */
+
+  int event_socket;
+
+  int info_socket;
+
+  /* Our own identity as a clock source */
+
+  struct ptp_announce_s own_identity;
+
+  /* Sequence number counters per message type */
+
+  uint16_t announce_seq;
+  uint16_t sync_seq;
+  uint16_t delay_req_seq;
+
+  /* Previous measurement and estimated clock drift rate */
+
+  struct timespec last_delta_timestamp;
+  int64_t last_delta_ns;
+  int64_t last_adjtime_ns;
+  long drift_avg_total_ms;
+  long drift_ppb;
+
+  /* Identity of currently selected clock source,
+   * from the latest announcement message.
+   *
+   * The timestamps are used for timeout when a source disappears.
+   * They are from the local CLOCK_MONOTONIC.
+   */
+
+  bool selected_source_valid;              /* True if operating as client */
+  struct ptp_announce_s selected_source;   /* Currently selected server */
+  struct timespec last_received_multicast; /* Any multicast packet */
+  struct timespec last_received_announce;  /* Announce from any server */
+  struct timespec last_received_sync;      /* Sync from selected source */
+
+  /* Last transmitted packet timestamps (CLOCK_MONOTONIC)
+   * Used to set transmission interval.
+   */
+
+  struct timespec last_transmitted_sync;
+  struct timespec last_transmitted_announce;
+  struct timespec last_transmitted_delayresp;
+  struct timespec last_transmitted_delayreq;
+
+  /* Timestamps related to path delay calculation (CLOCK_REALTIME) */
+
+  bool can_send_delayreq;
+  struct timespec delayreq_time;
+  int path_delay_avgcount;
+  long path_delay_ns;
+  long delayreq_interval;
+
+  /* Latest received packet and its timestamp (CLOCK_REALTIME) */
+
+  struct timespec rxtime;
+  union
+  {
+    struct ptp_header_s     header;
+    struct ptp_announce_s   announce;
+    struct ptp_sync_s       sync;
+    struct ptp_follow_up_s  follow_up;
+    struct ptp_delay_req_s  delay_req;
+    struct ptp_delay_resp_s delay_resp;
+    uint8_t                 raw[128];
+  } rxbuf;
+
+#ifndef ESP_PTP
+  uint8_t rxcmsg[CMSG_LEN(sizeof(struct timeval))];
+#endif // ESP_PTP
+
+  /* Buffered sync packet for two-step clock setting where server sends
+   * the accurate timestamp in a separate follow-up message.
+   */
+
+  struct ptp_sync_s twostep_packet;
+  struct timespec twostep_rxtime;
+};
+
+#ifdef CONFIG_NETUTILS_PTPD_SERVER
+#  define PTPD_POLL_INTERVAL CONFIG_NETUTILS_PTPD_SYNC_INTERVAL_MSEC
+#else
+#  define PTPD_POLL_INTERVAL CONFIG_NETUTILS_PTPD_TIMEOUT_MS
+#endif
+
+/* PTP debug messages are enabled by either CONFIG_DEBUG_NET_INFO
+ * or separately by CONFIG_NETUTILS_PTPD_DEBUG. This simplifies
+ * debugging without having excessive amount of logging from net.
+ */
+
+#ifdef ESP_PTP
+static const char *TAG = "ptpd";
+#define ptpinfo(format, ...) ESP_LOGI(TAG, format, ##__VA_ARGS__)
+#define ptpwarn(format, ...) ESP_LOGW(TAG, format, ##__VA_ARGS__)
+#define ptperr(format, ...) ESP_LOGE(TAG, format, ##__VA_ARGS__)
+#else
+#ifdef CONFIG_NETUTILS_PTPD_DEBUG
+#  define ptpinfo _info
+#  define ptpwarn _warn
+#  define ptperr  _err
+#else
+#  define ptpinfo ninfo
+#  define ptpwarn nwarn
+#  define ptperr  nerr
+#endif
+#endif // ESP_PTP
+
+#ifdef ESP_PTP
+static struct ptp_state_s *s_state;
+#endif
+
+/****************************************************************************
+ * Private Functions
+ ****************************************************************************/
+#ifdef ESP_PTP
+static void ptp_create_eth_frame(struct ptp_state_s *state, uint8_t *eth_frame, void *ptp_msg, uint16_t ptp_msg_len)
+{
+  struct eth_hdr eth_hdr = {
+    //.dest.addr = {0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E}, // TODO only for Pdelay_Req, Pdelay_Resp and Pdelay_Resp_Follow_Up
+    .dest.addr = {0x01, 0x1B, 0x19, 0x00, 0x00, 0x00}, // All except peer delay messages, ptp4l sends everything at this addr
+    .type = htons(ETH_TYPE_PTP)
+  };
+  memcpy(&eth_hdr.src.addr, state->intf_hw_addr, ETH_ADDR_LEN);
+
+  memcpy(eth_frame, &eth_hdr, sizeof(eth_hdr));
+  memcpy(eth_frame + sizeof(eth_hdr), ptp_msg, ptp_msg_len);
+}
+
+static int ptp_net_send(FAR struct ptp_state_s *state, void *ptp_msg, uint16_t ptp_msg_len, struct timespec *ts)
+{
+  uint8_t eth_frame[ptp_msg_len + ETH_HEADER_LEN];
+  ptp_create_eth_frame(state, eth_frame, ptp_msg, ptp_msg_len);
+
+  // wrap "Info Records Buffer" into union to ensure proper alignment of data (this is typically needed when
+  // accessing double word variables or structs containing double word variables)
+  union {
+      uint8_t info_recs_buff[L2TAP_IREC_SPACE(sizeof(struct timespec))];
+      l2tap_irec_hdr_t align;
+  } u;
+
+  l2tap_extended_buff_t ptp_msg_ext_buff;
+
+  ptp_msg_ext_buff.info_recs_len = sizeof(u.info_recs_buff);
+  ptp_msg_ext_buff.info_recs_buff = u.info_recs_buff;
+  ptp_msg_ext_buff.buff = eth_frame;
+  ptp_msg_ext_buff.buff_len = sizeof(eth_frame);
+
+  l2tap_irec_hdr_t *ts_info = L2TAP_IREC_FIRST(&ptp_msg_ext_buff);
+  ts_info->len = L2TAP_IREC_LEN(sizeof(struct timespec));
+  ts_info->type = L2TAP_IREC_TIME_STAMP;
+
+  int ret = write(state->ptp_socket, &ptp_msg_ext_buff, 0);
+
+  // check if write was successful, ts exists and ts_info is valid
+  if (ret > 0 && ts && ts_info->type == L2TAP_IREC_TIME_STAMP)
+    {
+      *ts = *(struct timespec *)ts_info->data;
+    }
+
+  return ret;
+}
+
+static int ptp_net_recv(FAR struct ptp_state_s *state, void *ptp_msg, uint16_t ptp_msg_len, struct timespec *ts)
+{
+  uint8_t eth_frame[ptp_msg_len + ETH_HEADER_LEN];
+
+  // wrap "Info Records Buffer" into union to ensure proper alignment of data (this is typically needed when
+  // accessing double word variables or structs containing double word variables)
+  union {
+      uint8_t info_recs_buff[L2TAP_IREC_SPACE(sizeof(struct timespec))];
+      l2tap_irec_hdr_t align;
+  } u;
+  l2tap_extended_buff_t ptp_msg_ext_buff;
+
+  ptp_msg_ext_buff.info_recs_len = sizeof(u.info_recs_buff);
+  ptp_msg_ext_buff.info_recs_buff = u.info_recs_buff;
+  ptp_msg_ext_buff.buff = eth_frame;
+  ptp_msg_ext_buff.buff_len = sizeof(eth_frame);
+
+  l2tap_irec_hdr_t *ts_info = L2TAP_IREC_FIRST(&ptp_msg_ext_buff);
+  ts_info->len = L2TAP_IREC_LEN(sizeof(struct timespec));
+  ts_info->type = L2TAP_IREC_TIME_STAMP;
+
+  int ret = read(state->ptp_socket, &ptp_msg_ext_buff, 0);
+
+  // check if read was successful, ts exists and ts_info is valid
+  if (ret > 0 && ts && ts_info->type == L2TAP_IREC_TIME_STAMP)
+    {
+      *ts = *(struct timespec *)ts_info->data;
+    }
+
+  memcpy(ptp_msg, &eth_frame[ETH_HEADER_LEN], ret);
+
+  return ret;
+}
+
+static int64_t timespec_to_ns(FAR const struct timespec *ts)
+{
+  return ts->tv_sec * NSEC_PER_SEC + (ts->tv_nsec);
+}
+#endif // ESP_PTP
+
+/* Convert from timespec to PTP format */
+
+static void timespec_to_ptp_format(FAR struct timespec *ts,
+                                   FAR uint8_t *timestamp)
+{
+  /* IEEE 1588 uses 48 bits for seconds and 32 bits for nanoseconds,
+   * both fields big-endian.
+   */
+
+#ifdef CONFIG_SYSTEM_TIME64
+  timestamp[0] = (uint8_t)(ts->tv_sec >> 40);
+  timestamp[1] = (uint8_t)(ts->tv_sec >> 32);
+#else
+  timestamp[0] = 0;
+  timestamp[1] = 0;
+#endif
+  timestamp[2] = (uint8_t)(ts->tv_sec >> 24);
+  timestamp[3] = (uint8_t)(ts->tv_sec >> 16);
+  timestamp[4] = (uint8_t)(ts->tv_sec >>  8);
+  timestamp[5] = (uint8_t)(ts->tv_sec >>  0);
+
+  timestamp[6] = (uint8_t)(ts->tv_nsec >>  24);
+  timestamp[7] = (uint8_t)(ts->tv_nsec >>  16);
+  timestamp[8] = (uint8_t)(ts->tv_nsec >>   8);
+  timestamp[9] = (uint8_t)(ts->tv_nsec >>   0);
+}
+
+/* Convert from PTP format to timespec */
+
+static void ptp_format_to_timespec(FAR const uint8_t *timestamp,
+                                   FAR struct timespec *ts)
+{
+  ts->tv_sec =
+      (((int64_t)timestamp[0]) << 40)
+    | (((int64_t)timestamp[1]) << 32)
+    | (((int64_t)timestamp[2]) << 24)
+    | (((int64_t)timestamp[3]) << 16)
+    | (((int64_t)timestamp[4]) <<  8)
+    | (((int64_t)timestamp[5]) <<  0);
+
+  ts->tv_nsec =
+      (((long)timestamp[6]) << 24)
+    | (((long)timestamp[7]) << 16)
+    | (((long)timestamp[8]) <<  8)
+    | (((long)timestamp[9]) <<  0);
+}
+
+/* Returns true if A is a better clock source than B.
+ * Implements Best Master Clock algorithm from IEEE-1588.
+ */
+
+static bool is_better_clock(FAR const struct ptp_announce_s *a,
+                            FAR const struct ptp_announce_s *b)
+{
+  if  (a->gm_priority1 < b->gm_priority1     /* Main priority field */
+    || a->gm_quality[0] < b->gm_quality[0]   /* Clock class */
+    || a->gm_quality[1] < b->gm_quality[1]   /* Clock accuracy */
+    || a->gm_quality[2] < b->gm_quality[2]   /* Clock variance high byte */
+    || a->gm_quality[3] < b->gm_quality[3]   /* Clock variance low byte */
+    || a->gm_priority2 < b->gm_priority2     /* Sub priority field */
+    || memcmp(a->gm_identity, b->gm_identity, sizeof(a->gm_identity)) < 0)
+    {
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+}
+
+static int64_t timespec_to_ms(FAR const struct timespec *ts)
+{
+  return ts->tv_sec * MSEC_PER_SEC + (ts->tv_nsec / NSEC_PER_MSEC);
+}
+
+/* Get positive or negative delta between two timespec values.
+ * If value would exceed int64 limit (292 years), return INT64_MAX/MIN.
+ */
+
+static int64_t timespec_delta_ns(FAR const struct timespec *ts1,
+                                 FAR const struct timespec *ts2)
+{
+  int64_t delta_s;
+
+  delta_s = ts1->tv_sec - ts2->tv_sec;
+
+#ifdef CONFIG_SYSTEM_TIME64
+  /* Conversion to nanoseconds could overflow if the system time is 64-bit */
+
+  if (delta_s >= INT64_MAX / NSEC_PER_SEC)
+    {
+      return INT64_MAX;
+    }
+  else if (delta_s <= INT64_MIN / NSEC_PER_SEC)
+    {
+      return INT64_MIN;
+    }
+#endif
+
+  return delta_s * NSEC_PER_SEC + (ts1->tv_nsec - ts2->tv_nsec);
+}
+
+/* Check if the currently selected source is still valid */
+
+static bool is_selected_source_valid(FAR struct ptp_state_s *state)
+{
+  struct timespec time_now;
+  struct timespec delta;
+
+  if ((state->selected_source.header.messagetype & PTP_MSGTYPE_MASK)
+      != PTP_MSGTYPE_ANNOUNCE)
+    {
+      return false; /* Uninitialized value */
+    }
+
+  /* Note: this uses monotonic clock to track the timeout even when
+   *       system clock is adjusted.
+   */
+
+  clock_gettime(CLOCK_MONOTONIC, &time_now);
+  clock_timespec_subtract(&time_now, &state->last_received_sync, &delta);
+
+  if (timespec_to_ms(&delta) > CONFIG_NETUTILS_PTPD_TIMEOUT_MS)
+    {
+#ifdef ESP_PTP
+      ESP_LOGD(TAG, "Too long time since received packet\n");
+#endif // ESP_PTP
+      return false; /* Too long time since received packet */
+    }
+
+  return true;
+}
+
+/* Increment sequence number for packet type, and copy to header */
+
+static void ptp_increment_sequence(FAR uint16_t *sequence_num,
+                                   FAR struct ptp_header_s *hdr)
+{
+  *sequence_num += 1;
+  hdr->sequenceid[0] = (uint8_t)(*sequence_num >> 8);
+  hdr->sequenceid[1] = (uint8_t)(*sequence_num);
+}
+
+/* Get sequence number from received packet */
+
+static uint16_t ptp_get_sequence(FAR const struct ptp_header_s *hdr)
+{
+  return ((uint16_t)hdr->sequenceid[0] << 8) | hdr->sequenceid[1];
+}
+
+/* Get current system timestamp as a timespec
+ * TODO: Possibly add support for selecting different clock or using
+ *       architecture-specific interface for clock access.
+ */
+
+static int ptp_gettime(FAR struct ptp_state_s *state,
+                       FAR struct timespec *ts)
+{
+  UNUSED(state);
+#ifdef ESP_PTP
+  return esp_eth_clock_gettime(CLOCK_PTP_SYSTEM, ts);
+#else
+  return clock_gettime(CLOCK_REALTIME, ts);
+#endif // ESP_PTP
+}
+
+/* Change current system timestamp by jumping */
+
+static int ptp_settime(FAR struct ptp_state_s *state,
+                       FAR struct timespec *ts)
+{
+  UNUSED(state);
+#ifdef ESP_PTP
+  return esp_eth_clock_settime(CLOCK_PTP_SYSTEM, ts);
+#else
+  return clock_settime(CLOCK_REALTIME, ts);
+#endif // ESP_PTP
+}
+
+#ifndef ESP_PTP
+/* Smoothly adjust timestamp. */
+
+static int ptp_adjtime(FAR struct ptp_state_s *state, int64_t delta_ns)
+{
+  struct timeval delta;
+
+  delta.tv_sec = delta_ns / NSEC_PER_SEC;
+  delta_ns -= (int64_t)delta.tv_sec * NSEC_PER_SEC;
+  delta.tv_usec = delta_ns / NSEC_PER_USEC;
+  return adjtime(&delta, NULL);
+}
+#endif // !ESP_PTP
+
+#ifndef ESP_PTP
+/* Get timestamp of latest received packet */
+
+static int ptp_getrxtime(FAR struct ptp_state_s *state,
+                         FAR struct msghdr *rxhdr,
+                         FAR struct timespec *ts)
+{
+  /* Get hardware or kernel timestamp if available */
+
+#ifdef CONFIG_NET_TIMESTAMP
+  struct cmsghdr *cmsg;
+
+  for_each_cmsghdr(cmsg, rxhdr)
+    {
+      if (cmsg->cmsg_level == SOL_SOCKET &&
+          cmsg->cmsg_type == SO_TIMESTAMP &&
+          cmsg->cmsg_len == CMSG_LEN(sizeof(struct timeval)))
+        {
+          TIMEVAL_TO_TIMESPEC((FAR struct timeval *)CMSG_DATA(cmsg), ts);
+
+          /* Sanity-check the value */
+
+          if (ts->tv_sec > 0 || ts->tv_nsec > 0)
+            {
+              return OK;
+            }
+        }
+    }
+
+  ptpwarn("CONFIG_NET_TIMESTAMP enabled but did not get packet timestamp\n");
+#endif
+
+  /* Fall back to current timestamp */
+
+  return ptp_gettime(state, ts);
+}
+#endif // !ESP_PTP
+
+/* Initialize PTP client/server state and create sockets */
+#ifdef ESP_PTP
+static int ptp_initialize_state(FAR struct ptp_state_s *state,
+                                FAR const char *interface)
+{
+  state->ptp_socket = open("/dev/net/tap", 0);
+  if (state->ptp_socket < 0)
+  {
+      ptperr("Failed to create tx socket: %d\n", errno);
+      return ERROR;
+  }
+
+  // Set Ethernet interface on which to get raw frames
+  if (ioctl(state->ptp_socket, L2TAP_S_INTF_DEVICE, interface) < 0)
+  {
+    ptperr("failed to set network interface at socket: %d\n", errno);
+    return ERROR;
+  }
+
+  // Set the Ethertype filter (frames with this type will be available through the state->tx_socket)
+  uint16_t eth_type_filter = ETH_TYPE_PTP;
+  if (ioctl(state->ptp_socket, L2TAP_S_RCV_FILTER, &eth_type_filter) < 0)
+  {
+    ptperr("failed to set Ethertype filter: %d\n", errno);
+    return ERROR;
+  }
+  // Enable time stamping in driver
+  esp_eth_handle_t eth_handle;
+  if (ioctl(state->ptp_socket, L2TAP_G_DEVICE_DRV_HNDL, &eth_handle) < 0)
+  {
+    ptperr("failed to get socket eth_handle %d\n", errno);
+    return ERROR;
+  }
+  state->cached_eth_handle = eth_handle;
+  esp_eth_clock_cfg_t clk_cfg = {
+    .eth_hndl = eth_handle,
+  };
+  esp_eth_clock_init(CLOCK_PTP_SYSTEM, &clk_cfg);
+
+  // Enable time stamping in L2TAP
+  if(ioctl(state->ptp_socket, L2TAP_S_TIMESTAMP_EN) < 0)
+  {
+    ptperr("failed to enable time stamping in l2 socket: %d\n", errno);
+    return ERROR;
+  }
+
+  // get HW address
+  esp_eth_ioctl(eth_handle, ETH_CMD_G_MAC_ADDR, &state->intf_hw_addr);
+
+  // Add well-known PTP multicast destination MAC addresses to the filter
+  uint8_t dest_addr[ETH_ADDR_LEN];
+  SET_MAC_ADDR(dest_addr, 0x01, 0x1B, 0x19, 0x00, 0x00, 0x00);
+  esp_eth_ioctl(eth_handle, ETH_CMD_ADD_MAC_FILTER, dest_addr);
+  SET_MAC_ADDR(dest_addr, 0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E);
+  esp_eth_ioctl(eth_handle, ETH_CMD_ADD_MAC_FILTER, dest_addr);
+
+  state->remote_time_ns_prev = 0;
+  state->local_time_ns_prev = 0;
+
+  /* PI controller gains (multiplicative, fixed-point * 1000).
+   * These are the default gains used when offset is small (<1ms).
+   * When offset is large, adaptive scaling increases them. */
+  state->offset_pi.kp = 150;   /* 0.15 as fixed-point *1000 */
+  state->offset_pi.ki = 10;    /* 0.01 as fixed-point *1000 */
+  state->offset_pi.drift_acc = 0;
+
+  state->own_identity.header.version = 2;
+  state->own_identity.header.domain = CONFIG_NETUTILS_PTPD_DOMAIN;
+  state->own_identity.header.sourceidentity[0] = state->intf_hw_addr[0];
+  state->own_identity.header.sourceidentity[1] = state->intf_hw_addr[1];
+  state->own_identity.header.sourceidentity[2] = state->intf_hw_addr[2];
+  state->own_identity.header.sourceidentity[3] = 0xff;
+  state->own_identity.header.sourceidentity[4] = 0xfe;
+  state->own_identity.header.sourceidentity[5] = state->intf_hw_addr[3];
+  state->own_identity.header.sourceidentity[6] = state->intf_hw_addr[4];
+  state->own_identity.header.sourceidentity[7] = state->intf_hw_addr[5];
+  state->own_identity.header.sourceportindex[0] = 0;
+  state->own_identity.header.sourceportindex[1] = 1;
+#ifdef CONFIG_NETUTILS_PTPD_SERVER
+  state->own_identity.gm_priority1 = CONFIG_NETUTILS_PTPD_PRIORITY1;
+  state->own_identity.gm_quality[0] = CONFIG_NETUTILS_PTPD_CLASS;
+  state->own_identity.gm_quality[1] = CONFIG_NETUTILS_PTPD_ACCURACY;
+  state->own_identity.gm_quality[2] = 0xff; /* No variance estimate */
+  state->own_identity.gm_quality[3] = 0xff;
+  state->own_identity.gm_priority2 = CONFIG_NETUTILS_PTPD_PRIORITY2;
+  memcpy(state->own_identity.gm_identity,
+         state->own_identity.header.sourceidentity,
+         sizeof(state->own_identity.gm_identity));
+  state->own_identity.timesource = CONFIG_NETUTILS_PTPD_CLOCKSOURCE;
+#else
+  state->own_identity.gm_priority1 = 255; // When daemon is statically configured as slave, set the worst
+#endif
+
+  /* --- UDP socket setup for AES67 interoperability ---
+   * In addition to L2 TAP, set up standard UDP sockets on the PTP
+   * multicast group (224.0.1.129) so we can communicate with devices
+   * that use PTP over UDP/IPv4 (which is most AES67 gear). */
+
+  /* Get interface IP for binding and IGMP */
+  esp_netif_t *netif = esp_netif_get_handle_from_ifkey(interface);
+  if (netif) {
+    esp_netif_ip_info_t ip_info;
+    if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
+      state->interface_addr.sin_family = AF_INET;
+      state->interface_addr.sin_addr.s_addr = ip_info.ip.addr;
+    }
+  }
+
+  /* Create UDP sockets */
+  state->tx_socket = socket(AF_INET, SOCK_DGRAM, 0);
+  state->event_socket = socket(AF_INET, SOCK_DGRAM, 0);
+  state->info_socket = socket(AF_INET, SOCK_DGRAM, 0);
+
+  if (state->tx_socket < 0 || state->event_socket < 0 || state->info_socket < 0) {
+    ptperr("Failed to create UDP sockets for PTP: %d\n", errno);
+    /* Non-fatal: L2 transport still works */
+  } else {
+    struct sockaddr_in bind_addr;
+    memset(&bind_addr, 0, sizeof(bind_addr));
+    bind_addr.sin_family = AF_INET;
+    bind_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+
+    /* Join PTP multicast group via IGMP */
+    struct ip_mreq mreq;
+    mreq.imr_multiaddr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    mreq.imr_interface.s_addr = state->interface_addr.sin_addr.s_addr;
+
+    if (setsockopt(state->event_socket, IPPROTO_IP, IP_ADD_MEMBERSHIP,
+                   &mreq, sizeof(mreq)) < 0) {
+      ptperr("Failed to join PTP multicast on event socket: %d\n", errno);
+    }
+    if (setsockopt(state->info_socket, IPPROTO_IP, IP_ADD_MEMBERSHIP,
+                   &mreq, sizeof(mreq)) < 0) {
+      ptperr("Failed to join PTP multicast on info socket: %d\n", errno);
+    }
+
+    /* Allow address reuse so multiple sockets can bind to the same port */
+    int reuse = 1;
+    setsockopt(state->event_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
+    setsockopt(state->info_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
+
+    /* Bind event socket to port 319 */
+    bind_addr.sin_port = htons(PTP_UDP_PORT_EVENT);
+    if (bind(state->event_socket, (struct sockaddr *)&bind_addr,
+             sizeof(bind_addr)) < 0) {
+      ptperr("Failed to bind event socket to port %d: %d\n",
+             PTP_UDP_PORT_EVENT, errno);
+    }
+
+    /* Bind info socket to port 320 */
+    bind_addr.sin_port = htons(PTP_UDP_PORT_INFO);
+    if (bind(state->info_socket, (struct sockaddr *)&bind_addr,
+             sizeof(bind_addr)) < 0) {
+      ptperr("Failed to bind info socket to port %d: %d\n",
+             PTP_UDP_PORT_INFO, errno);
+    }
+
+    /* Bind TX socket to local interface address */
+    bind_addr.sin_addr = state->interface_addr.sin_addr;
+    bind_addr.sin_port = 0;  /* Any port */
+    bind(state->tx_socket, (struct sockaddr *)&bind_addr, sizeof(bind_addr));
+
+    /* Set multicast TTL */
+    int ttl = 64;
+    setsockopt(state->tx_socket, IPPROTO_IP, IP_MULTICAST_TTL,
+               &ttl, sizeof(ttl));
+
+    ptpinfo("UDP PTP sockets ready (event:%d, info:%d, tx:%d)\n",
+            state->event_socket, state->info_socket, state->tx_socket);
+  }
+
+  s_state = state;
+  return OK;
+}
+#else
+static int ptp_initialize_state(FAR struct ptp_state_s *state,
+                                FAR const char *interface)
+{
+  int ret;
+  struct ifreq req;
+  struct sockaddr_in bind_addr;
+
+#ifdef CONFIG_NET_TIMESTAMP
+  int arg;
+#endif
+
+  /* Create sockets */
+
+  state->tx_socket = socket(AF_INET, SOCK_DGRAM, 0);
+  if (state->tx_socket < 0)
+    {
+      ptperr("Failed to create tx socket: %d\n", errno);
+      return ERROR;
+    }
+
+  state->event_socket = socket(AF_INET, SOCK_DGRAM, 0);
+  if (state->event_socket < 0)
+    {
+      ptperr("Failed to create event socket: %d\n", errno);
+      return ERROR;
+    }
+
+
+  state->info_socket = socket(AF_INET, SOCK_DGRAM, 0);
+  if (state->info_socket < 0)
+    {
+      ptperr("Failed to create info socket: %d\n", errno);
+      return ERROR;
+    }
+
+  /* Get address information of the specified interface for binding socket
+   * Only supports IPv4 currently.
+   */
+
+  memset(&req, 0, sizeof(req));
+  strncpy(req.ifr_name, interface, sizeof(req.ifr_name));
+
+  if (ioctl(state->event_socket, SIOCGIFADDR, (unsigned long)&req) < 0)
+    {
+      ptperr("Failed to get IP address information for interface %s\n",
+             interface);
+      return ERROR;
+    }
+
+  state->interface_addr = *(struct sockaddr_in *)&req.ifr_ifru.ifru_addr;
+
+  /* Get hardware address to initialize the identity field in header.
+   * Clock identity is EUI-64, which we make from EUI-48.
+   */
+
+  if (ioctl(state->event_socket, SIOCGIFHWADDR, (unsigned long)&req) < 0)
+    {
+      ptperr("Failed to get HW address information for interface %s\n",
+             interface);
+      return ERROR;
+    }
+
+  state->own_identity.header.version = 2;
+  state->own_identity.header.domain = CONFIG_NETUTILS_PTPD_DOMAIN;
+  state->own_identity.header.sourceidentity[0] = req.ifr_hwaddr.sa_data[0];
+  state->own_identity.header.sourceidentity[1] = req.ifr_hwaddr.sa_data[1];
+  state->own_identity.header.sourceidentity[2] = req.ifr_hwaddr.sa_data[2];
+  state->own_identity.header.sourceidentity[3] = 0xff;
+  state->own_identity.header.sourceidentity[4] = 0xfe;
+  state->own_identity.header.sourceidentity[5] = req.ifr_hwaddr.sa_data[3];
+  state->own_identity.header.sourceidentity[6] = req.ifr_hwaddr.sa_data[4];
+  state->own_identity.header.sourceidentity[7] = req.ifr_hwaddr.sa_data[5];
+  state->own_identity.header.sourceportindex[0] = 0;
+  state->own_identity.header.sourceportindex[1] = 1;
+  state->own_identity.gm_priority1 = CONFIG_NETUTILS_PTPD_PRIORITY1;
+  state->own_identity.gm_quality[0] = CONFIG_NETUTILS_PTPD_CLASS;
+  state->own_identity.gm_quality[1] = CONFIG_NETUTILS_PTPD_ACCURACY;
+  state->own_identity.gm_quality[2] = 0xff; /* No variance estimate */
+  state->own_identity.gm_quality[3] = 0xff;
+  state->own_identity.gm_priority2 = CONFIG_NETUTILS_PTPD_PRIORITY2;
+  memcpy(state->own_identity.gm_identity,
+         state->own_identity.header.sourceidentity,
+         sizeof(state->own_identity.gm_identity));
+  state->own_identity.timesource = CONFIG_NETUTILS_PTPD_CLOCKSOURCE;
+
+  /* Subscribe to PTP multicast address */
+
+  bind_addr.sin_family = AF_INET;
+  bind_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+
+  clock_gettime(CLOCK_MONOTONIC, &state->last_received_multicast);
+
+  ret = ipmsfilter(&state->interface_addr.sin_addr,
+                   &bind_addr.sin_addr,
+                   MCAST_INCLUDE);
+  if (ret < 0)
+    {
+      ptperr("Failed to bind multicast address: %d\n", errno);
+      return ERROR;
+    }
+
+  /* Bind socket for events */
+
+  bind_addr.sin_port = htons(PTP_UDP_PORT_EVENT);
+  ret = bind(state->event_socket, (struct sockaddr *)&bind_addr,
+             sizeof(bind_addr));
+  if (ret < 0)
+    {
+      ptperr("Failed to bind to udp port %d\n", bind_addr.sin_port);
+      return ERROR;
+    }
+
+#ifdef CONFIG_NET_TIMESTAMP
+  arg = 1;
+  ret = setsockopt(state->event_socket, SOL_SOCKET, SO_TIMESTAMP,
+                   &arg, sizeof(arg));
+
+  if (ret < 0)
+    {
+      ptperr("Failed to enable SO_TIMESTAMP: %s\n", strerror(errno));
+
+      /* PTPD can operate without, but with worse accuracy */
+    }
+#endif
+
+  /* Bind socket for announcements */
+
+  bind_addr.sin_port = htons(PTP_UDP_PORT_INFO);
+  ret = bind(state->info_socket, (struct sockaddr *)&bind_addr,
+             sizeof(bind_addr));
+  if (ret < 0)
+    {
+      ptperr("Failed to bind to udp port %d\n", bind_addr.sin_port);
+      return ERROR;
+    }
+
+  /* Bind TX socket to interface address (local addr cannot be multicast) */
+
+  bind_addr.sin_addr = state->interface_addr.sin_addr;
+  ret = bind(state->tx_socket, (struct sockaddr *)&bind_addr,
+             sizeof(bind_addr));
+  if (ret < 0)
+    {
+      ptperr("Failed to bind tx to port %d\n", bind_addr.sin_port);
+      return ERROR;
+    }
+
+  return OK;
+}
+#endif // ESP_PTP
+
+/* Unsubscribe multicast and destroy sockets */
+
+static int ptp_destroy_state(FAR struct ptp_state_s *state)
+{
+#ifdef ESP_PTP
+  // Remove well-known PTP multicast destination MAC addresses from the filter
+  esp_eth_handle_t eth_handle;
+  if (ioctl(state->ptp_socket, L2TAP_G_DEVICE_DRV_HNDL, &eth_handle) < 0)
+  {
+    ptperr("failed to get socket eth_handle %d\n", errno);
+    return ERROR;
+  }
+  uint8_t dest_addr[ETH_ADDR_LEN];
+  SET_MAC_ADDR(dest_addr, 0x01, 0x1B, 0x19, 0x00, 0x00, 0x00);
+  esp_eth_ioctl(eth_handle, ETH_CMD_DEL_MAC_FILTER, dest_addr);
+  SET_MAC_ADDR(dest_addr, 0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E);
+  esp_eth_ioctl(eth_handle, ETH_CMD_DEL_MAC_FILTER, dest_addr);
+
+  if (state->ptp_socket > 0)
+  {
+    close(state->ptp_socket);
+    state->ptp_socket = -1;
+  }
+#else
+  struct in_addr mcast_addr;
+
+  mcast_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+  ipmsfilter(&state->interface_addr.sin_addr,
+              &mcast_addr,
+              MCAST_EXCLUDE);
+
+  if (state->tx_socket > 0)
+    {
+      close(state->tx_socket);
+      state->tx_socket = -1;
+    }
+
+  if (state->event_socket > 0)
+    {
+      close(state->event_socket);
+      state->event_socket = -1;
+    }
+
+  if (state->info_socket > 0)
+    {
+      close(state->info_socket);
+      state->info_socket = -1;
+    }
+#endif // ESP_PTP
+  return OK;
+}
+
+#ifndef ESP_PTP
+/* Re-subscribe multicast address.
+ * This can become necessary if Ethernet interface gets reset or if external
+ * IGMP-compliant Ethernet switch gets plugged in.
+ */
+
+static int ptp_check_multicast_status(FAR struct ptp_state_s *state)
+{
+#if CONFIG_NETUTILS_PTPD_MULTICAST_TIMEOUT_MS > 0
+  struct in_addr mcast_addr;
+  struct timespec time_now;
+  struct timespec delta;
+
+  clock_gettime(CLOCK_MONOTONIC, &time_now);
+  clock_timespec_subtract(&time_now, &state->last_received_multicast,
+                          &delta);
+
+  if (timespec_to_ms(&delta) > CONFIG_NETUTILS_PTPD_MULTICAST_TIMEOUT_MS)
+    {
+      /* Remove and re-add the multicast group */
+
+      state->last_received_multicast = time_now;
+
+      mcast_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+      ipmsfilter(&state->interface_addr.sin_addr,
+                 &mcast_addr,
+                 MCAST_EXCLUDE);
+
+      return ipmsfilter(&state->interface_addr.sin_addr,
+                        &mcast_addr,
+                        MCAST_INCLUDE);
+    }
+
+#else
+  UNUSED(state);
+#endif /* CONFIG_NETUTILS_PTPD_MULTICAST_TIMEOUT_MS */
+
+  return OK;
+}
+#endif // !ESP_PTP
+
+/* Send PTP server announcement packet */
+
+static int ptp_send_announce(FAR struct ptp_state_s *state)
+{
+  struct ptp_announce_s msg;
+#ifndef ESP_PTP
+  struct sockaddr_in addr;
+#endif // !ESP_PTP
+  struct timespec ts;
+  int ret;
+
+#ifndef ESP_PTP
+  addr.sin_family      = AF_INET;
+  addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+  addr.sin_port        = htons(PTP_UDP_PORT_INFO);
+#endif // !ESP_PTP
+
+  memset(&msg, 0, sizeof(msg));
+  msg = state->own_identity;
+  msg.header.messagetype = PTP_MSGTYPE_ANNOUNCE;
+  msg.header.messagelength[1] = sizeof(msg);
+
+  ptp_increment_sequence(&state->announce_seq, &msg.header);
+  ptp_gettime(state, &ts);
+  timespec_to_ptp_format(&ts, msg.origintimestamp);
+
+#ifdef ESP_PTP
+  /* Send via L2 TAP for hardware timestamped transport */
+  ret = ptp_net_send(state, &msg, sizeof(msg), NULL);
+
+  /* Also send via UDP for interoperability with standard PTP/AES67 devices */
+  {
+    struct sockaddr_in udp_addr;
+    udp_addr.sin_family      = AF_INET;
+    udp_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    udp_addr.sin_port        = htons(PTP_UDP_PORT_INFO);
+    sendto(state->tx_socket, &msg, sizeof(msg), 0,
+           (struct sockaddr *)&udp_addr, sizeof(udp_addr));
+  }
+#else
+  ret = sendto(state->tx_socket, &msg, sizeof(msg), 0,
+    (struct sockaddr *)&addr, sizeof(addr));
+#endif // ESP_PTP
+
+  if (ret < 0)
+    {
+      ptperr("sendto failed: %d", errno);
+    }
+  else
+    {
+      ESP_LOGD(TAG, "Sent announce, seq %ld",
+              (long)ptp_get_sequence(&msg.header));
+    }
+
+  return ret;
+}
+
+/* Send PTP server synchronization packet */
+
+static int ptp_send_sync(FAR struct ptp_state_s *state)
+{
+#ifndef ESP_PTP
+  struct msghdr txhdr;
+  struct iovec txiov;
+#endif // !ESP_PTP
+  struct ptp_sync_s msg;
+#ifndef ESP_PTP
+  struct sockaddr_in addr;
+#endif // !ESP_PTP
+  struct timespec ts;
+#ifndef ESP_PTP
+  uint8_t controlbuf[64];
+#endif // !ESP_PTP
+  int ret;
+
+#ifndef ESP_PTP
+  memset(&txhdr, 0, sizeof(txhdr));
+  memset(&txiov, 0, sizeof(txiov));
+
+  addr.sin_family      = AF_INET;
+  addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+  addr.sin_port        = htons(PTP_UDP_PORT_EVENT);
+#endif // !ESP_PTP
+
+  memset(&msg, 0, sizeof(msg));
+  msg.header = state->own_identity.header;
+  msg.header.messagetype = PTP_MSGTYPE_SYNC;
+  msg.header.messagelength[1] = sizeof(msg);
+
+#ifdef CONFIG_NETUTILS_PTPD_TWOSTEP_SYNC
+  msg.header.flags[0] = PTP_FLAGS0_TWOSTEP;
+#endif
+
+#ifndef ESP_PTP
+  txhdr.msg_name = &addr;
+  txhdr.msg_namelen = sizeof(addr);
+  txhdr.msg_iov = &txiov;
+  txhdr.msg_iovlen = 1;
+  txhdr.msg_control = controlbuf;
+  txhdr.msg_controllen = sizeof(controlbuf);
+  txiov.iov_base = &msg;
+  txiov.iov_len = sizeof(msg);
+#endif //!ESP_PTP
+
+  /* Timestamp and send the sync message */
+
+  ptp_increment_sequence(&state->sync_seq, &msg.header);
+  ptp_gettime(state, &ts);
+  timespec_to_ptp_format(&ts, msg.origintimestamp);
+
+#ifdef ESP_PTP
+  ret = ptp_net_send(state, &msg, sizeof(msg), &ts);
+
+  /* Also send sync via UDP for AES67 interoperability */
+  {
+    struct sockaddr_in udp_addr;
+    udp_addr.sin_family      = AF_INET;
+    udp_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    udp_addr.sin_port        = htons(PTP_UDP_PORT_EVENT);
+    sendto(state->tx_socket, &msg, sizeof(msg), 0,
+           (struct sockaddr *)&udp_addr, sizeof(udp_addr));
+  }
+#else
+  ret = sendmsg(state->tx_socket, &txhdr, 0);
+#endif // ESP_PTP
+  if (ret < 0)
+    {
+      ptperr("sendmsg for sync message failed: %d\n", errno);
+      return ret;
+    }
+
+#ifdef CONFIG_NETUTILS_PTPD_TWOSTEP_SYNC
+#ifndef ESP_PTP
+  /* Get timestamp after send completes and send follow-up message
+   *
+   * TODO: Implement SO_TIMESTAMPING and use the actual tx timestamp here.
+   */
+
+  ptp_gettime(state, &ts);
+#endif // !ESP_PTP
+  timespec_to_ptp_format(&ts, msg.origintimestamp);
+  msg.header.messagetype = PTP_MSGTYPE_FOLLOW_UP;
+  msg.header.flags[0] = 0;
+#ifdef ESP_PTP
+  ret = ptp_net_send(state, &msg, sizeof(msg), NULL);
+
+  /* Also send follow-up via UDP */
+  {
+    struct sockaddr_in udp_addr;
+    udp_addr.sin_family      = AF_INET;
+    udp_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    udp_addr.sin_port        = htons(PTP_UDP_PORT_INFO);
+    sendto(state->tx_socket, &msg, sizeof(msg), 0,
+           (struct sockaddr *)&udp_addr, sizeof(udp_addr));
+  }
+#else
+  addr.sin_port = htons(PTP_UDP_PORT_INFO);
+
+  ret = sendto(state->tx_socket, &msg, sizeof(msg), 0,
+               (struct sockaddr *)&addr, sizeof(addr));
+#endif // ESP_PTP
+  if (ret < 0)
+    {
+      ptperr("sendto for follow-up message failed: %d\n", errno);
+      return ret;
+    }
+
+  ESP_LOGD(TAG, "Sent sync + follow-up, seq %ld",
+          (long)ptp_get_sequence(&msg.header));
+#else
+  ESP_LOGD(TAG, "Sent sync, seq %ld",
+          (long)ptp_get_sequence(&msg.header));
+#endif /* CONFIG_NETUTILS_PTPD_TWOSTEP_SYNC */
+
+  return OK;
+}
+
+/* Send delay request packet to selected source */
+
+static int ptp_send_delay_req(FAR struct ptp_state_s *state)
+{
+  struct ptp_delay_req_s req;
+#ifndef ESP_PTP
+  struct sockaddr_in addr;
+#endif // !ESP_PTP
+  int ret;
+
+#ifndef ESP_PTP
+  addr.sin_family      = AF_INET;
+  addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+  addr.sin_port        = htons(PTP_UDP_PORT_EVENT);
+#endif // !ESP_PTP
+
+  memset(&req, 0, sizeof(req));
+  req.header = state->own_identity.header;
+  req.header.messagetype = PTP_MSGTYPE_DELAY_REQ;
+  req.header.messagelength[1] = sizeof(req);
+  ptp_increment_sequence(&state->delay_req_seq, &req.header);
+
+  ptp_gettime(state, &state->delayreq_time);
+  timespec_to_ptp_format(&state->delayreq_time, req.origintimestamp);
+
+#ifdef ESP_PTP
+  ret = ptp_net_send(state, &req, sizeof(req), &state->delayreq_time);
+
+  /* Also send delay_req via UDP */
+  {
+    struct sockaddr_in udp_addr;
+    udp_addr.sin_family      = AF_INET;
+    udp_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    udp_addr.sin_port        = htons(PTP_UDP_PORT_EVENT);
+    sendto(state->tx_socket, &req, sizeof(req), 0,
+           (struct sockaddr *)&udp_addr, sizeof(udp_addr));
+  }
+#else
+  ret = sendto(state->tx_socket, &req, sizeof(req), 0,
+               (FAR struct sockaddr *)&addr, sizeof(addr));
+
+  /* Get timestamp after send completes.
+   * TODO: Implement SO_TIMESTAMPING and use the actual tx timestamp here.
+   */
+
+  ptp_gettime(state, &state->delayreq_time);
+#endif // ESP_PTP
+
+  if (ret < 0)
+    {
+      ptperr("sendto failed: %d", errno);
+    }
+  else
+    {
+      clock_gettime(CLOCK_MONOTONIC, &state->last_transmitted_delayreq);
+      ESP_LOGD(TAG, "Sent delay req, seq %ld",
+              (long)ptp_get_sequence(&req.header));
+    }
+
+  return ret;
+}
+
+/* Check if we need to send packets */
+
+static int ptp_periodic_send(FAR struct ptp_state_s *state)
+{
+#ifdef CONFIG_NETUTILS_PTPD_SERVER
+  /* If there is no better master clock on the network,
+   * act as the reference source and send server packets.
+   */
+
+  if (!state->selected_source_valid)
+    {
+      struct timespec time_now;
+      struct timespec delta;
+
+      clock_gettime(CLOCK_MONOTONIC, &time_now);
+      clock_timespec_subtract(&time_now,
+        &state->last_transmitted_announce, &delta);
+      if (timespec_to_ms(&delta)
+          > CONFIG_NETUTILS_PTPD_ANNOUNCE_INTERVAL_MSEC)
+        {
+          state->last_transmitted_announce = time_now;
+          ptp_send_announce(state);
+        }
+
+      clock_timespec_subtract(&time_now,
+        &state->last_transmitted_sync, &delta);
+      if (timespec_to_ms(&delta) > CONFIG_NETUTILS_PTPD_SYNC_INTERVAL_MSEC)
+        {
+          state->last_transmitted_sync = time_now;
+          ptp_send_sync(state);
+        }
+    }
+#endif /* CONFIG_NETUTILS_PTPD_SERVER */
+
+#ifdef CONFIG_NETUTILS_PTPD_SEND_DELAYREQ
+  if (state->selected_source_valid && state->can_send_delayreq)
+    {
+      struct timespec time_now;
+      struct timespec delta;
+
+      clock_gettime(CLOCK_MONOTONIC, &time_now);
+      clock_timespec_subtract(&time_now,
+                              &state->last_transmitted_delayreq, &delta);
+
+      if (timespec_to_ms(&delta) > state->delayreq_interval * MSEC_PER_SEC)
+        {
+          ptp_send_delay_req(state);
+        }
+    }
+#endif
+
+  return OK;
+}
+
+/* Process received PTP announcement */
+
+static int ptp_process_announce(FAR struct ptp_state_s *state,
+                                FAR struct ptp_announce_s *msg)
+{
+  clock_gettime(CLOCK_MONOTONIC, &state->last_received_announce);
+
+  /* Log announce only when it's from a new/different GM than our current source,
+   * or if it's better than us and we haven't selected it yet. */
+  bool better = is_better_clock(msg, &state->own_identity);
+  bool is_new_gm = !state->selected_source_valid ||
+                   memcmp(msg->gm_identity,
+                          state->selected_source.gm_identity,
+                          sizeof(msg->gm_identity)) != 0;
+
+  if (is_new_gm || !state->selected_source_valid) {
+    ESP_LOGI(TAG, "Announce from GM %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X "
+             "(p1=%u, class=%u, acc=%u, p2=%u) %s",
+             msg->gm_identity[0], msg->gm_identity[1],
+             msg->gm_identity[2], msg->gm_identity[3],
+             msg->gm_identity[4], msg->gm_identity[5],
+             msg->gm_identity[6], msg->gm_identity[7],
+             msg->gm_priority1,
+             msg->gm_quality[0],   /* clock class */
+             msg->gm_quality[1],   /* clock accuracy */
+             msg->gm_priority2,
+             better ? "<- BETTER than us" : "(not better)");
+  }
+
+  if (better)
+    {
+      if (!state->selected_source_valid ||
+          is_better_clock(msg, &state->selected_source))
+        {
+          ESP_LOGI(TAG, "Selecting GM %02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X as clock source",
+                   msg->gm_identity[0], msg->gm_identity[1],
+                   msg->gm_identity[2], msg->gm_identity[3],
+                   msg->gm_identity[4], msg->gm_identity[5],
+                   msg->gm_identity[6], msg->gm_identity[7]);
+
+          state->selected_source = *msg;
+          state->last_received_sync = state->last_received_announce;
+          state->path_delay_avgcount = 0;
+          state->path_delay_ns = 0;
+          state->delayreq_time.tv_sec = 0;
+        }
+    }
+
+  return OK;
+}
+
+#ifdef ESP_PTP
+static void ptp_lock_local_clock_freq(FAR struct ptp_state_s *state,
+                                  FAR struct timespec *remote_timestamp,
+                                  FAR struct timespec *local_timestamp)
+{
+  /* Compute clock offset against master */
+  int64_t offset_ns = timespec_delta_ns(remote_timestamp, local_timestamp);
+  offset_ns += state->path_delay_ns;
+
+  /* Outlier filter: reject measurements where the offset changes by
+   * more than 2ms from the previous sample. These are caused by network
+   * jitter (delayed packets), not real clock error. Skip adjustment
+   * but still update timestamps for the next cycle. */
+  if (state->last_offset_ns != 0) {
+    int64_t delta = offset_ns - state->last_offset_ns;
+    if (delta > 500000LL || delta < -500000LL) {
+      ESP_LOGD(TAG, "Outlier filtered: offset %+lld ns (prev %+lld, delta %+lld)",
+               offset_ns, state->last_offset_ns, delta);
+      /* Update prev timestamps but skip PI adjustment */
+      state->remote_time_ns_prev = timespec_to_ns(remote_timestamp);
+      state->local_time_ns_prev = timespec_to_ns(local_timestamp);
+      state->last_offset_ns = offset_ns;
+      state->last_delta_ns = offset_ns;
+      return;
+    }
+  }
+  state->last_offset_ns = offset_ns;
+
+  /* Convert offset from nanoseconds to ppb using the actual sync interval.
+   * ppb = offset_ns * 1e9 / interval_ns */
+  int64_t local_time_ns_now = timespec_to_ns(local_timestamp);
+  int64_t interval_ns = 1000000000LL;
+  if (state->local_time_ns_prev > 0) {
+    int64_t measured = local_time_ns_now - state->local_time_ns_prev;
+    if (measured > 100000000LL && measured < 5000000000LL) {
+      interval_ns = measured;
+    }
+  }
+
+  int64_t offset_ppb = (offset_ns * 1000000000LL) / interval_ns;
+
+  /* Adaptive gain scaling: use stronger gains when offset is large
+   * to converge quickly, then ease off for stable fine tracking.
+   *   > 1ms offset: gains * 3 (aggressive pull-in)
+   *   > 100us offset: gains * 2 (moderate convergence)
+   *   < 100us offset: base gains (fine tracking) */
+  int32_t kp = state->offset_pi.kp;
+  int32_t ki = state->offset_pi.ki;
+  int64_t abs_offset = (offset_ns < 0) ? -offset_ns : offset_ns;
+
+  if (abs_offset > 1000000) {       /* > 1ms */
+    kp *= 2;
+    ki *= 2;
+  }
+  /* No extra scaling for 100us-1ms range -- let the base gains handle it */
+
+  /* PI controller with multiplicative gains.
+   * P term = offset_ppb * kp / 1000
+   * I term: drift_acc += offset_ppb * ki / 1000 */
+  int32_t p_term = (int32_t)((offset_ppb * kp) / 1000);
+
+  state->offset_pi.drift_acc += (int32_t)((offset_ppb * ki) / 1000);
+
+  /* Clamp I term to +/- 50 ppm to prevent overshoot */
+  if (state->offset_pi.drift_acc > 50000) {
+    state->offset_pi.drift_acc = 50000;
+  } else if (state->offset_pi.drift_acc < -50000) {
+    state->offset_pi.drift_acc = -50000;
+  }
+
+  int32_t adj_ppb = p_term + state->offset_pi.drift_acc;
+
+  /* Clamp total output to +/- 200 ppm */
+  if (adj_ppb > 200000) adj_ppb = 200000;
+  if (adj_ppb < -200000) adj_ppb = -200000;
+
+#ifndef AES67_DISABLE_PTP_PI
+  /* Use the ppb-based API (ETH_MAC_ESP_CMD_ADJ_PTP_TIME) which calls
+   * emac_hal_ptp_adj_inc. This is idempotent: it always computes the
+   * new addend relative to the stored base addend, avoiding the
+   * compounding problem of the freq_scale API. */
+  esp_err_t adj_err = esp_eth_ioctl(state->cached_eth_handle,
+                                     ETH_MAC_ESP_CMD_ADJ_PTP_TIME, &adj_ppb);
+  if (adj_err != ESP_OK) {
+    ESP_LOGW(TAG, "PTP freq adj failed: %s", esp_err_to_name(adj_err));
+  }
+#else
+  (void)adj_ppb;
+  /* PI disabled: PTP clock runs free, no frequency discipline */
+#endif
+
+  /* Track timestamps for drift measurement */
+  int64_t remote_time_ns = timespec_to_ns(remote_timestamp);
+  int64_t local_time_ns = timespec_to_ns(local_timestamp);
+
+  if (state->remote_time_ns_prev != 0 && state->local_time_ns_prev != 0) {
+    int64_t local_delta_ns = local_time_ns - state->local_time_ns_prev;
+    int64_t remote_delta_ns = remote_time_ns - state->remote_time_ns_prev;
+    if (local_delta_ns > 0 && local_delta_ns < 5000000000LL) {
+      int64_t tick_diff = remote_delta_ns - local_delta_ns;
+      state->drift_ppb = (long)((tick_diff * 1000000000LL) / local_delta_ns);
+    }
+  }
+
+  state->remote_time_ns_prev = remote_time_ns;
+  state->local_time_ns_prev = local_time_ns;
+  state->last_delta_ns = offset_ns;
+
+  ESP_LOGD(TAG, "offset %+lld ns, p=%+ld, i=%+ld, adj=%+ld ppb",
+           offset_ns, (long)p_term, (long)state->offset_pi.drift_acc, (long)adj_ppb);
+
+  // Get the path delay only when clock is stable enough. If we were in process of adjustion (speeding/slowing slave),
+  // we would get incorrect delay
+  int64_t diff = llabs(offset_ns) - llabs(state->last_offset_ns);
+  static int cnt = 0;
+  if (llabs(diff) < CONFIG_NETUTILS_PTPD_PATH_DELAY_STABILITY_NS) {
+    if (cnt <= 3)
+      cnt++;
+  } else {
+    cnt = 0;
+  }
+  if (cnt > 3)
+  {
+    state->can_send_delayreq = true;
+  }
+  state->last_offset_ns = offset_ns;
+}
+
+void ptp_clean_after_step(FAR struct ptp_state_s *state)
+{
+  state->remote_time_ns_prev = 0;
+  state->local_time_ns_prev = 0;
+
+  state->offset_pi.drift_acc = 0;
+  state->last_offset_ns = 0;
+}
+#endif // ESP_PTP
+
+/* Update local clock either by smooth adjustment or by jumping.
+ * Remote time was remote_timestamp at local_timestamp.
+ */
+
+static int ptp_update_local_clock(FAR struct ptp_state_s *state,
+                                  FAR struct timespec *remote_timestamp,
+                                  FAR struct timespec *local_timestamp)
+{
+  int ret = OK;
+  int64_t delta_ns;
+  int64_t absdelta_ns;
+  const int64_t adj_limit_ns = CONFIG_NETUTILS_PTPD_SETTIME_THRESHOLD_MS
+                               * (int64_t)NSEC_PER_MSEC;
+
+  ESP_LOGD(TAG, "Local time: %lld.%09ld, remote time %lld.%09ld",
+          (long long)local_timestamp->tv_sec,
+          (long)local_timestamp->tv_nsec,
+          (long long)remote_timestamp->tv_sec,
+          (long)remote_timestamp->tv_nsec);
+
+  delta_ns = timespec_delta_ns(remote_timestamp, local_timestamp);
+  delta_ns += state->path_delay_ns;
+  absdelta_ns = (delta_ns < 0) ? -delta_ns : delta_ns;
+
+  if (absdelta_ns > adj_limit_ns)
+    {
+      /* Large difference, move by jumping.
+       * Account for delay since packet was received.
+       */
+
+      struct timespec new_time;
+      ptp_gettime(state, &new_time);
+      clock_timespec_subtract(&new_time, local_timestamp, &new_time);
+      clock_timespec_add(&new_time, remote_timestamp, &new_time);
+      ret = ptp_settime(state, &new_time);
+
+      /* Reinitialize drift adjustment parameters */
+
+      state->last_delta_timestamp = new_time;
+      state->last_delta_ns = 0;
+      state->last_adjtime_ns = 0;
+      state->drift_avg_total_ms = 0;
+      state->drift_ppb = 0;
+
+#ifdef ESP_PTP
+      ptp_clean_after_step(state);
+#endif // ESP_PTP
+
+      if (ret == OK)
+        {
+          ESP_LOGI(TAG, "Jumped to timestamp %lld.%09ld s",
+                  (long long)new_time.tv_sec, (long)new_time.tv_nsec);
+        }
+      else
+        {
+          ptperr("ptp_settime() failed: %d\n", errno);
+        }
+    }
+  else
+    {
+#ifdef ESP_PTP
+	  ptp_lock_local_clock_freq(state, remote_timestamp, local_timestamp);
+#else
+      /* Track drift rate based on two consecutive measurements and
+       * the adjustment that was made previously.
+       */
+
+      int64_t drift_ppb;
+      struct timespec interval;
+      int interval_ms;
+      int max_avg_period_ms;
+      int64_t adjustment_ns;
+
+      clock_timespec_subtract(local_timestamp,
+                              &state->last_delta_timestamp,
+                              &interval);
+      interval_ms = timespec_to_ms(&interval);
+
+      if (interval_ms > 0 && interval_ms < CONFIG_NETUTILS_PTPD_TIMEOUT_MS)
+        {
+          drift_ppb = (delta_ns - state->last_delta_ns) * MSEC_PER_SEC
+                      / interval_ms;
+        }
+      else
+        {
+          ptpwarn("Measurement interval out of range: %d ms\n", interval_ms);
+          drift_ppb = 0;
+          interval_ms = 1;
+        }
+
+      /* Account for the adjustment previously made */
+
+      drift_ppb += state->last_adjtime_ns * MSEC_PER_SEC
+                  / CONFIG_CLOCK_ADJTIME_PERIOD_MS;
+
+      if (drift_ppb > CONFIG_CLOCK_ADJTIME_SLEWLIMIT_PPM * 1000 ||
+          drift_ppb < -CONFIG_CLOCK_ADJTIME_SLEWLIMIT_PPM * 1000)
+        {
+          ptpwarn("Drift estimate out of range: %lld\n",
+                  (long long)drift_ppb);
+          drift_ppb = state->drift_ppb;
+        }
+
+      /* Take direct average of drift estimate for first measurements,
+       * after that update the exponential sliding average.
+       * Measurements are weighted according to the interval, because
+       * drift estimate is more accurate over longer timespan.
+       */
+
+      state->drift_avg_total_ms += interval_ms;
+      max_avg_period_ms = CONFIG_NETUTILS_PTPD_DRIFT_AVERAGE_S
+                          * MSEC_PER_SEC;
+      if (state->drift_avg_total_ms > max_avg_period_ms)
+        {
+          state->drift_avg_total_ms = max_avg_period_ms;
+        }
+
+      state->drift_ppb += (drift_ppb - state->drift_ppb) * interval_ms
+                        / state->drift_avg_total_ms;
+
+      /* Compute the value we need to give to adjtime() to match the
+       * drift rate.
+       */
+
+      adjustment_ns = state->drift_ppb * CONFIG_CLOCK_ADJTIME_PERIOD_MS
+                      / MSEC_PER_SEC;
+
+      /* Drift estimation ensures local clock runs at same rate as remote.
+       *
+       * Adding the current clock offset to adjustment brings the clocks
+       * to match. To avoid individual outliers from causing jitter, we
+       * take the larger signed value of two previous deltas. This is based
+       * on the logic that packets can get delayed in transit, but do not
+       * travel backwards in time.
+       *
+       * Clock offset is applied over ADJTIME_PERIOD. If there is significant
+       * noise in measurements, increasing ADJTIME_PERIOD will reduce its
+       * effect on the local clock run rate.
+       */
+
+      if (state->last_delta_ns > delta_ns)
+        {
+          adjustment_ns += state->last_delta_ns;
+        }
+      else
+        {
+          adjustment_ns += delta_ns;
+        }
+
+      /* Apply adjustment and store information for next time */
+
+      state->last_delta_ns = delta_ns;
+      state->last_delta_timestamp = *local_timestamp;
+      state->last_adjtime_ns = adjustment_ns;
+
+      ESP_LOGD(TAG, "Delta: %+lld ns, adjustment %+lld ns, drift rate %+lld ppb",
+              (long long)delta_ns,
+              (long long)state->last_adjtime_ns,
+              (long long)state->drift_ppb);
+
+      ret = ptp_adjtime(state, adjustment_ns);
+
+      if (ret != OK)
+        {
+          ptperr("ptp_adjtime() failed: %d\n", errno);
+        }
+
+      /* Check if clock is stable enough for sending delay requests */
+
+      if (absdelta_ns < CONFIG_NETUTILS_PTPD_MAX_PATH_DELAY_NS)
+        {
+          state->can_send_delayreq = true;
+        }
+#endif // ESP_PTP
+    }
+
+  return ret;
+}
+
+/* Process received PTP sync packet */
+
+static int ptp_process_sync(FAR struct ptp_state_s *state,
+                            FAR struct ptp_sync_s *msg)
+{
+  struct timespec remote_time;
+
+  if (memcmp(msg->header.sourceidentity,
+             state->selected_source.header.sourceidentity,
+             sizeof(msg->header.sourceidentity)) != 0)
+    {
+      /* This packet wasn't from the currently selected source */
+#ifdef ESP_PTP
+      ESP_LOGD(TAG, "This packet wasn't from the currently selected source");
+#endif // ESP_PTP
+      return OK;
+    }
+
+  /* Update timeout tracking */
+
+  clock_gettime(CLOCK_MONOTONIC, &state->last_received_sync);
+
+  if (msg->header.flags[0] & PTP_FLAGS0_TWOSTEP)
+    {
+      /* We need to wait for a follow-up packet before setting the clock. */
+
+      state->twostep_rxtime = state->rxtime;
+      state->twostep_packet = *msg;
+      ESP_LOGD(TAG, "Waiting for follow-up");
+      return OK;
+    }
+
+  /* Update local clock */
+
+  ptp_format_to_timespec(msg->origintimestamp, &remote_time);
+  return ptp_update_local_clock(state, &remote_time, &state->rxtime);
+}
+
+static int ptp_process_followup(FAR struct ptp_state_s *state,
+                                FAR struct ptp_follow_up_s *msg)
+{
+  struct timespec remote_time;
+
+  if (memcmp(msg->header.sourceidentity,
+             state->twostep_packet.header.sourceidentity,
+             sizeof(msg->header.sourceidentity)) != 0)
+    {
+      return OK; /* This packet wasn't from the currently selected source */
+    }
+
+  if (ptp_get_sequence(&msg->header)
+      != ptp_get_sequence(&state->twostep_packet.header))
+    {
+      ptpwarn("PTP follow-up packet sequence %ld does not match initial "
+              "sync packet sequence %ld, ignoring\n",
+        (long)ptp_get_sequence(&msg->header),
+        (long)ptp_get_sequence(&state->twostep_packet.header));
+      return OK;
+    }
+
+  /* Update local clock based on the remote timestamp we received now
+   * and the local timestamp of when the sync packet was received.
+   */
+
+  ptp_format_to_timespec(msg->origintimestamp, &remote_time);
+  return ptp_update_local_clock(state, &remote_time, &state->twostep_rxtime);
+}
+
+static int ptp_process_delay_req(FAR struct ptp_state_s *state,
+                                 FAR struct ptp_delay_req_s *msg)
+{
+  struct ptp_delay_resp_s resp;
+#ifndef ESP_PTP
+  struct sockaddr_in addr;
+#endif // !ESP_PTP
+  int ret;
+
+  if (state->selected_source_valid)
+    {
+      /* We are operating as a client, ignore delay requests */
+
+      return OK;
+    }
+
+#ifndef ESP_PTP
+  addr.sin_family      = AF_INET;
+  addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+  addr.sin_port        = htons(PTP_UDP_PORT_INFO);
+#endif // !ESP_PTP
+
+  memset(&resp, 0, sizeof(resp));
+  resp.header = state->own_identity.header;
+  resp.header.messagetype = PTP_MSGTYPE_DELAY_RESP;
+  resp.header.messagelength[1] = sizeof(resp);
+  timespec_to_ptp_format(&state->rxtime, resp.receivetimestamp);
+  memcpy(resp.reqidentity, msg->header.sourceidentity,
+         sizeof(resp.reqidentity));
+  memcpy(resp.reqportindex, msg->header.sourceportindex,
+         sizeof(resp.reqportindex));
+  memcpy(resp.header.sequenceid, msg->header.sequenceid,
+         sizeof(resp.header.sequenceid));
+  resp.header.logmessageinterval = CONFIG_NETUTILS_PTPD_DELAYRESP_INTERVAL;
+
+#ifdef ESP_PTP
+  ret = ptp_net_send(state, &resp, sizeof(resp), NULL);
+
+  /* Also send delay_resp via UDP */
+  {
+    struct sockaddr_in udp_addr;
+    udp_addr.sin_family      = AF_INET;
+    udp_addr.sin_addr.s_addr = htonl(PTP_MULTICAST_ADDR);
+    udp_addr.sin_port        = htons(PTP_UDP_PORT_INFO);
+    sendto(state->tx_socket, &resp, sizeof(resp), 0,
+           (struct sockaddr *)&udp_addr, sizeof(udp_addr));
+  }
+#else
+  ret = sendto(state->tx_socket, &resp, sizeof(resp), 0,
+               (FAR struct sockaddr *)&addr, sizeof(addr));
+#endif // ESP_PTP
+
+  if (ret < 0)
+    {
+      ptperr("sendto failed: %d", errno);
+    }
+  else
+    {
+      clock_gettime(CLOCK_MONOTONIC, &state->last_transmitted_delayresp);
+      ESP_LOGD(TAG, "Sent delay resp, seq %ld",
+              (long)ptp_get_sequence(&msg->header));
+    }
+
+  return ret;
+}
+
+static int ptp_process_delay_resp(FAR struct ptp_state_s *state,
+                                  FAR struct ptp_delay_resp_s *msg)
+{
+  int64_t path_delay;
+  int64_t sync_delay;
+  struct timespec remote_rxtime;
+  uint16_t sequence;
+  int interval;
+
+  if (!state->selected_source_valid ||
+      memcmp(msg->header.sourceidentity,
+             state->selected_source.header.sourceidentity,
+             sizeof(msg->header.sourceidentity)) != 0 ||
+      memcmp(msg->reqidentity,
+             state->own_identity.header.sourceidentity,
+             sizeof(msg->reqidentity)) != 0)
+    {
+      return OK; /* This packet wasn't for us */
+    }
+
+  sequence = ptp_get_sequence(&msg->header);
+
+  if (sequence != state->delay_req_seq)
+    {
+      ptpwarn("Ignoring out-of-sequence delay resp (%d vs. expected %d)\n",
+              (int)sequence, (int)state->delay_req_seq);
+      return OK;
+    }
+
+  /* Path delay is calculated as the average between delta for sync
+   * message and delta for delay req message.
+   * (IEEE-1588 section 11.3: Delay request-response mechanism)
+   */
+
+  ptp_format_to_timespec(msg->receivetimestamp, &remote_rxtime);
+  path_delay = timespec_delta_ns(&remote_rxtime, &state->delayreq_time);
+  sync_delay = state->path_delay_ns - state->last_delta_ns;
+  path_delay = (path_delay + sync_delay) / 2;
+
+  if (path_delay >= 0 && path_delay < CONFIG_NETUTILS_PTPD_MAX_PATH_DELAY_NS)
+    {
+      if (state->path_delay_avgcount <
+          CONFIG_NETUTILS_PTPD_DELAYREQ_AVGCOUNT)
+        {
+          state->path_delay_avgcount++;
+        }
+
+      state->path_delay_ns += (path_delay - state->path_delay_ns)
+                              / state->path_delay_avgcount;
+
+      ESP_LOGD(TAG, "Path delay: %ld ns (avg: %ld ns)",
+        (long)path_delay, (long)state->path_delay_ns);
+    }
+  else
+    {
+      ESP_LOGD(TAG, "Path delay out of range: %lld ns",
+              (long long)path_delay);
+    }
+
+  /* Calculate interval until next packet */
+
+  if (msg->header.logmessageinterval <= 12)
+    {
+      interval = (1 << msg->header.logmessageinterval);
+    }
+  else
+    {
+      interval = 4096; /* Refuse to obey excessively long intervals */
+    }
+
+  /* Randomize up to 2x nominal delay) */
+
+  state->delayreq_interval = interval + (random() % interval);
+
+  return OK;
+}
+
+/* Determine received packet type and process it */
+
+static int ptp_process_rx_packet(FAR struct ptp_state_s *state,
+                                 ssize_t length)
+{
+  if (length < sizeof(struct ptp_header_s))
+    {
+      ptpwarn("Ignoring invalid PTP packet, length only %d bytes\n",
+              (int)length);
+      return OK;
+    }
+
+  if (state->rxbuf.header.domain != CONFIG_NETUTILS_PTPD_DOMAIN)
+    {
+      /* Part of different clock domain, ignore */
+
+      return OK;
+    }
+
+  clock_gettime(CLOCK_MONOTONIC, &state->last_received_multicast);
+
+  switch (state->rxbuf.header.messagetype & PTP_MSGTYPE_MASK)
+  {
+#ifdef CONFIG_NETUTILS_PTPD_CLIENT
+    case PTP_MSGTYPE_ANNOUNCE:
+      ESP_LOGD(TAG, "Got announce packet, seq %ld",
+              (long)ptp_get_sequence(&state->rxbuf.header));
+      return ptp_process_announce(state, &state->rxbuf.announce);
+
+    case PTP_MSGTYPE_SYNC:
+      ESP_LOGD(TAG, "Got sync packet, seq %ld",
+              (long)ptp_get_sequence(&state->rxbuf.header));
+      return ptp_process_sync(state, &state->rxbuf.sync);
+
+    case PTP_MSGTYPE_FOLLOW_UP:
+      ESP_LOGD(TAG, "Got follow-up packet, seq %ld",
+              (long)ptp_get_sequence(&state->rxbuf.header));
+      return ptp_process_followup(state, &state->rxbuf.follow_up);
+
+    case PTP_MSGTYPE_DELAY_RESP:
+      ESP_LOGD(TAG, "Got delay-resp, seq %ld",
+              (long)ptp_get_sequence(&state->rxbuf.header));
+      return ptp_process_delay_resp(state, &state->rxbuf.delay_resp);
+#endif
+
+#ifdef CONFIG_NETUTILS_PTPD_SERVER
+    case PTP_MSGTYPE_DELAY_REQ:
+      ESP_LOGD(TAG, "Got delay req, seq %ld",
+              (long)ptp_get_sequence(&state->rxbuf.header));
+      return ptp_process_delay_req(state, &state->rxbuf.delay_req);
+#endif
+
+    default:
+      ptpinfo("Ignoring unknown PTP packet type: 0x%02x\n",
+              state->rxbuf.header.messagetype);
+      return OK;
+  }
+}
+
+/* Signal handler for status / stop requests */
+#ifndef ESP_PTP
+static void ptp_signal_handler(int signo, FAR siginfo_t *siginfo,
+                               FAR void *context)
+{
+  FAR struct ptp_state_s *state = (FAR struct ptp_state_s *)siginfo->si_user;
+
+  if (signo == SIGHUP)
+    {
+      state->stop = true;
+    }
+  else if (signo == SIGUSR1 && siginfo->si_value.sival_ptr)
+    {
+      state->status_req =
+        *(FAR struct ptpd_statusreq_s *)siginfo->si_value.sival_ptr;
+    }
+}
+
+static void ptp_setup_sighandlers(FAR struct ptp_state_s *state)
+{
+  struct sigaction act;
+
+  act.sa_sigaction = ptp_signal_handler;
+  sigfillset(&act.sa_mask);
+  act.sa_flags = SA_SIGINFO;
+  act.sa_user = state;
+
+  sigaction(SIGHUP, &act, NULL);
+  sigaction(SIGUSR1, &act, NULL);
+}
+#endif // !ESP_PTP
+
+/* Process status information request */
+
+static void ptp_process_statusreq(FAR struct ptp_state_s *state)
+{
+  FAR struct ptpd_status_s *status;
+
+  if (!state->status_req.dest)
+    {
+      return; /* No active request */
+    }
+
+  status = state->status_req.dest;
+  status->clock_source_valid = state->selected_source_valid;
+
+  if (status->clock_source_valid)
+    {
+      /* Copy relevant parts of announce info to status struct */
+
+      FAR struct ptp_announce_s *s = &state->selected_source;
+
+      memcpy(status->clock_source_info.id,
+             s->header.sourceidentity,
+             sizeof(status->clock_source_info.id));
+
+      status->clock_source_info.utcoffset =
+          (int16_t)(((uint16_t)s->utcoffset[0] << 8) | s->utcoffset[1]);
+      status->clock_source_info.priority1 = s->gm_priority1;
+      status->clock_source_info.clockclass = s->gm_quality[0];
+      status->clock_source_info.accuracy = s->gm_quality[1];
+      status->clock_source_info.priority2 = s->gm_priority2;
+      status->clock_source_info.variance =
+          ((uint16_t)s->gm_quality[2] << 8) | s->gm_quality[3];
+
+      memcpy(status->clock_source_info.gm_id,
+             s->gm_identity,
+             sizeof(status->clock_source_info.gm_id));
+
+      status->clock_source_info.stepsremoved =
+          ((uint16_t)s->stepsremoved[0] << 8) | s->stepsremoved[1];
+      status->clock_source_info.timesource = s->timesource;
+    }
+
+  /* Copy latest adjustment info */
+
+  status->last_clock_update = state->last_delta_timestamp;
+  status->last_delta_ns     = state->last_delta_ns;
+  status->last_adjtime_ns   = state->last_adjtime_ns;
+  status->drift_ppb         = state->drift_ppb;
+  status->path_delay_ns     = state->path_delay_ns;
+
+  /* Copy timestamps */
+
+  status->last_received_multicast    = state->last_received_multicast;
+  status->last_received_announce     = state->last_received_announce;
+  status->last_received_sync         = state->last_received_sync;
+  status->last_transmitted_sync      = state->last_transmitted_sync;
+  status->last_transmitted_announce  = state->last_transmitted_announce;
+  status->last_transmitted_delayresp = state->last_transmitted_delayresp;
+  status->last_transmitted_delayreq  = state->last_transmitted_delayreq;
+
+  /* Post semaphore to inform that we are done */
+
+  if (state->status_req.done)
+    {
+      sem_post(state->status_req.done);
+    }
+
+  state->status_req.done = NULL;
+  state->status_req.dest = NULL;
+}
+
+/* Main PTPD task */
+#ifdef ESP_PTP
+static void ptp_daemon(void *task_param)
+#else
+static int ptp_daemon(int argc, FAR char** argv)
+#endif // ESP_PTP
+{
+  FAR const char *interface = "eth0";
+  FAR struct ptp_state_s *state;
+#ifdef ESP_PTP
+  struct pollfd pollfds[3]; /* L2 TAP + UDP event (319) + UDP info (320) */
+#else
+  struct pollfd pollfds[2];
+  struct msghdr rxhdr;
+  struct iovec rxiov;
+#endif // ESP_PTP
+  int ret;
+
+#ifndef ESP_PTP
+  memset(&rxhdr, 0, sizeof(rxhdr));
+  memset(&rxiov, 0, sizeof(rxiov));
+#endif // !ESP_PTP
+
+  state = calloc(1, sizeof(struct ptp_state_s));
+
+#ifdef ESP_PTP
+  if (task_param != NULL)
+    {
+      interface = task_param;
+    }
+#else
+  if (argc > 1)
+    {
+      interface = argv[1];
+    }
+#endif // ESP_PTP
+
+  if (ptp_initialize_state(state, interface) != OK)
+    {
+      ptperr("Failed to initialize PTP state, exiting\n");
+
+      ptp_destroy_state(state);
+      free(state);
+
+#ifdef ESP_PTP
+      goto err;
+#else
+      return ERROR;
+#endif // ESP_PTP
+    }
+#ifndef ESP_PTP
+  ptp_setup_sighandlers(state);
+#endif // !ESP_PTP
+
+  pollfds[0].events = POLLIN;
+#ifdef ESP_PTP
+  /* Poll L2 TAP (for HW timestamps) and UDP event socket (for
+   * interoperability with devices that send PTP over UDP/IPv4).
+   * The info socket (port 320) is also polled for announce and
+   * follow-up messages. */
+  pollfds[0].fd = state->ptp_socket;
+  pollfds[1].events = POLLIN;
+  pollfds[1].fd = state->event_socket;
+  pollfds[2].events = POLLIN;
+  pollfds[2].fd = state->info_socket;
+#else
+  pollfds[0].fd = state->event_socket;
+  pollfds[1].events = POLLIN;
+  pollfds[1].fd = state->info_socket;
+#endif // ESP_PTP
+
+  while (!state->stop)
+    {
+      state->can_send_delayreq = false;
+
+#ifndef ESP_PTP
+      rxhdr.msg_name = NULL;
+      rxhdr.msg_namelen = 0;
+      rxhdr.msg_iov = &rxiov;
+      rxhdr.msg_iovlen = 1;
+      rxhdr.msg_control = &state->rxcmsg;
+      rxhdr.msg_controllen = sizeof(state->rxcmsg);
+      rxhdr.msg_flags = 0;
+      rxiov.iov_base = &state->rxbuf;
+      rxiov.iov_len = sizeof(state->rxbuf);
+#endif // !ESP_PTP
+
+      pollfds[0].revents = 0;
+      pollfds[1].revents = 0;
+#ifdef ESP_PTP
+      pollfds[2].revents = 0;
+      ret = poll(pollfds, 3, PTPD_POLL_INTERVAL);
+#else
+      ret = poll(pollfds, 2, PTPD_POLL_INTERVAL);
+#endif
+
+      if (pollfds[0].revents)
+        {
+          /* Receive time-critical packet, potentially with cmsg
+           * indicating the timestamp.
+           */
+
+#ifdef ESP_PTP
+          ret = ptp_net_recv(state, &state->rxbuf, sizeof(state->rxbuf), &state->rxtime);
+#else
+          ret = recvmsg(state->event_socket, &rxhdr, MSG_DONTWAIT);
+#endif // ESP_PTP
+
+          if (ret > 0)
+            {
+#ifndef ESP_PTP
+              ptp_getrxtime(state, &rxhdr, &state->rxtime);
+#endif
+              ptp_process_rx_packet(state, ret);
+            }
+        }
+
+#ifdef ESP_PTP
+      if (pollfds[1].revents)
+        {
+          /* Receive PTP packet from UDP event socket (port 319:
+           * sync, delay_req - time-critical). */
+
+          ret = recv(state->event_socket, &state->rxbuf, sizeof(state->rxbuf),
+                    MSG_DONTWAIT);
+          if (ret > 0)
+            {
+              ptp_gettime(state, &state->rxtime);
+              ptp_process_rx_packet(state, ret);
+            }
+        }
+
+      if (pollfds[2].revents)
+        {
+          /* Receive PTP packet from UDP info socket (port 320:
+           * announce, follow_up, delay_resp - non-time-critical). */
+
+          ret = recv(state->info_socket, &state->rxbuf, sizeof(state->rxbuf),
+                    MSG_DONTWAIT);
+          if (ret > 0)
+            {
+              ptp_gettime(state, &state->rxtime);
+              ptp_process_rx_packet(state, ret);
+            }
+        }
+#else
+      if (pollfds[1].revents)
+        {
+          /* Receive non-time-critical packet. */
+
+          ret = recv(state->info_socket, &state->rxbuf, sizeof(state->rxbuf),
+                    MSG_DONTWAIT);
+          if (ret > 0)
+            {
+              ptp_process_rx_packet(state, ret);
+            }
+        }
+
+      if (pollfds[0].revents == 0 && pollfds[1].revents == 0)
+        {
+          /* No packets received, check for multicast timeout */
+
+          ptp_check_multicast_status(state);
+        }
+#endif // !ESP_PTP
+      ptp_periodic_send(state);
+
+      state->selected_source_valid = is_selected_source_valid(state);
+      ptp_process_statusreq(state);
+
+#ifdef ESP_PTP
+      /* Yield to lower-priority tasks (IDLE watchdog) after each iteration.
+       * poll() may return immediately if the L2 TAP socket has pending events,
+       * which would otherwise starve the IDLE task and trigger the watchdog. */
+      vTaskDelay(1);
+#endif
+    }
+  ptp_destroy_state(state);
+  free(state);
+
+#ifdef ESP_PTP
+err:
+  s_state = NULL;
+  vTaskDelete(NULL);
+#else
+  return 0;
+#endif // ESP_PTP
+}
+
+/****************************************************************************
+ * Public Functions
+ ****************************************************************************/
+
+/****************************************************************************
+ * Name: ptpd_start
+ *
+ * Description:
+ *   Start the PTP daemon and bind it to specified interface.
+ *
+ * Input Parameters:
+ *   interface - Name of the network interface to bind to, e.g. "eth0"
+ *
+ * Returned Value:
+ *   On success, the non-negative task ID of the PTP daemon is returned;
+ *   On failure, a negated errno value is returned.
+ *
+ ****************************************************************************/
+
+int ptpd_start(FAR const char *interface)
+{
+#ifdef ESP_PTP
+  if (s_state == NULL) {
+    xTaskCreate(ptp_daemon, "PTPD", CONFIG_NETUTILS_PTPD_STACKSIZE,
+              (void *)interface, tskIDLE_PRIORITY + 2, NULL);
+    return 1;
+  }
+  ESP_LOGE(TAG, "Other instance of PTP is already running");
+  return -1;
+#else
+  int pid;
+  FAR char *task_argv[] = {
+    (FAR char *)interface,
+    NULL
+  };
+
+  pid = task_create("PTPD", CONFIG_NETUTILS_PTPD_SERVERPRIO,
+    CONFIG_NETUTILS_PTPD_STACKSIZE, ptp_daemon, task_argv);
+
+  /* Use kill with signal 0 to check if the process is still alive
+   * after initialization.
+   */
+
+  usleep(USEC_PER_TICK);
+  if (kill(pid, 0) != OK)
+    {
+      return ERROR;
+    }
+  else
+    {
+      return pid;
+    }
+#endif // ESP_PTP
+}
+
+/****************************************************************************
+ * Name: ptpd_status
+ *
+ * Description:
+ *   Query status from a running PTP daemon.
+ *
+ * Input Parameters:
+ *   pid     - Process ID previously returned by ptpd_start()
+ *   status  - Pointer to storage for status information.
+ *
+ * Returned Value:
+ *   On success, returns OK.
+ *   On failure, a negated errno value is returned.
+ *
+ * Assumptions/Limitations:
+ *   Multiple threads with priority less than CONFIG_NETUTILS_PTPD_SERVERPRIO
+ *   can request status simultaneously. If higher priority threads request
+ *   status simultaneously, some of the requests may timeout.
+ *
+ ****************************************************************************/
+
+int ptpd_status(int pid, FAR struct ptpd_status_s *status)
+{
+#ifdef ESP_PTP
+  int ret = 0;
+  sem_t donesem;
+  struct ptpd_statusreq_s req;
+  struct timespec timeout;
+
+  /* Fill in the status request */
+
+  memset(status, 0, sizeof(struct ptpd_status_s));
+  sem_init(&donesem, 0, 0);
+  req.done = &donesem;
+  req.dest = status;
+
+  s_state->status_req = req;
+
+  /* Wait for status request to be handled */
+  clock_gettime(CLOCK_REALTIME, &timeout); // sem_timedwait uses CLOCK_REALTIME
+  timeout.tv_sec += 1;
+
+  if (sem_timedwait(&donesem, &timeout) != 0)
+    {
+      req.done = NULL;
+      req.dest = NULL;
+      s_state->status_req = req;
+      ret = -errno;
+    }
+  sem_destroy(&donesem);
+
+  return ret;
+#endif
+#ifndef CONFIG_BUILD_FLAT
+
+  /* TODO: Use SHM memory to pass the status information if processes
+   * do not share the same memory space.
+   */
+
+  return -ENOTSUP;
+
+#else
+
+  int ret = OK;
+  sem_t donesem;
+  struct ptpd_statusreq_s req;
+  union sigval val;
+  struct timespec timeout;
+
+  /* Fill in the status request */
+
+  memset(status, 0, sizeof(struct ptpd_status_s));
+  sem_init(&donesem, 0, 0);
+  req.done = &donesem;
+  req.dest = status;
+  val.sival_ptr = &req;
+
+  if (sigqueue(pid, SIGUSR1, val) != OK)
+    {
+      return -errno;
+    }
+
+  /* Wait for status request to be handled */
+
+  clock_gettime(CLOCK_MONOTONIC, &timeout);
+  timeout.tv_sec += 1;
+  if (sem_clockwait(&donesem, CLOCK_MONOTONIC, &timeout) != 0)
+    {
+      ret = -errno;
+    }
+
+  return ret;
+
+#endif /* CONFIG_BUILD_FLAT */
+}
+
+/****************************************************************************
+ * Name: ptpd_stop
+ *
+ * Description:
+ *   Stop PTP daemon
+ *
+ * Input Parameters:
+ *   pid     - Process ID previously returned by ptpd_start()
+ *
+ * Returned Value:
+ *   On success, returns OK.
+ *   On failure, a negated errno value is returned.
+ *
+ ****************************************************************************/
+
+int ptpd_stop(int pid)
+{
+#ifdef ESP_PTP
+  s_state->stop = true;
+  return OK;
+#else
+  if (kill(pid, SIGHUP) == OK)
+    {
+      return OK;
+    }
+  else
+    {
+      return -errno;
+    }
+#endif
+}

+ 143 - 0
components/aes67/third_party/ptpd/ptpv2.h

@@ -0,0 +1,143 @@
+/*
+ * SPDX-FileCopyrightText: 2020-2024 The Apache Software Foundation
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * SPDX-FileContributor: 2024 Espressif Systems (Shanghai) CO LTD
+ */
+
+/****************************************************************************
+ * apps/netutils/ptpd/ptpv2.h
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The
+ * ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the
+ * License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ ****************************************************************************/
+
+#ifndef __APPS_NETUTILS_PTPD_PTPV2_H
+#define __APPS_NETUTILS_PTPD_PTPV2_H
+
+/****************************************************************************
+ * Included Files
+ ****************************************************************************/
+
+#include <stdint.h>
+
+/****************************************************************************
+ * Pre-processor Definitions
+ ****************************************************************************/
+
+/* Time-critical messages (id < 8) are sent to port 319,
+ * other messages to port 320.
+ */
+
+#define PTP_UDP_PORT_EVENT 319
+#define PTP_UDP_PORT_INFO  320
+
+/* Multicast address to send to: 224.0.1.129 */
+
+#define PTP_MULTICAST_ADDR ((in_addr_t)0xE0000181)
+
+/* Message types */
+
+#define PTP_MSGTYPE_MASK       0x0F
+#define PTP_MSGTYPE_SYNC          0
+#define PTP_MSGTYPE_DELAY_REQ     1
+#define PTP_MSGTYPE_FOLLOW_UP     8
+#define PTP_MSGTYPE_DELAY_RESP    9
+#define PTP_MSGTYPE_ANNOUNCE     11
+
+/* Message flags */
+
+#define PTP_FLAGS0_TWOSTEP        (1 << 1)
+
+/****************************************************************************
+ * Public Types
+ ****************************************************************************/
+
+/* Defined in IEEE 1588-2008 Precision Time Protocol
+ * All multi-byte fields are big-endian.
+ */
+
+/* Common header for all message types */
+
+struct ptp_header_s
+{
+  uint8_t messagetype;
+  uint8_t version;
+  uint8_t messagelength[2];
+  uint8_t domain;
+  uint8_t reserved1;
+  uint8_t flags[2];
+  uint8_t correction[8];
+  uint8_t reserved2[4];
+  uint8_t sourceidentity[8];
+  uint8_t sourceportindex[2];
+  uint8_t sequenceid[2];
+  uint8_t controlfield;
+  uint8_t logmessageinterval;
+};
+
+/* Announce a master clock */
+
+struct ptp_announce_s
+{
+  struct ptp_header_s header;
+  uint8_t origintimestamp[10];
+  uint8_t utcoffset[2];
+  uint8_t reserved;
+  uint8_t gm_priority1;
+  uint8_t gm_quality[4];
+  uint8_t gm_priority2;
+  uint8_t gm_identity[8];
+  uint8_t stepsremoved[2];
+  uint8_t timesource;
+};
+
+/* Sync: transmit timestamp from master clock */
+
+struct ptp_sync_s
+{
+  struct ptp_header_s header;
+  uint8_t origintimestamp[10];
+};
+
+/* FollowUp: actual timestamp of when sync message was sent */
+
+struct ptp_follow_up_s
+{
+  struct ptp_header_s header;
+  uint8_t origintimestamp[10];
+};
+
+/* DelayReq: request delay measurement */
+
+struct ptp_delay_req_s
+{
+  struct ptp_header_s header;
+  uint8_t origintimestamp[10];
+};
+
+/* DelayResp: response to DelayReq */
+
+struct ptp_delay_resp_s
+{
+  struct ptp_header_s header;
+  uint8_t receivetimestamp[10];
+  uint8_t reqidentity[8];
+  uint8_t reqportindex[2];
+};
+
+#endif /* __APPS_NETUTILS_PTPD_PTPV2_H */

+ 262 - 0
components/aes67/web/index.html

@@ -0,0 +1,262 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>AES67 Node</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{background:#111;color:#ccc;font:13px/1.5 'JetBrains Mono','Fira Code',Consolas,monospace}
+a{color:#e8a030}
+.wrap{max-width:1100px;margin:0 auto;padding:8px 12px}
+header{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #333;margin-bottom:12px}
+header h1{font-size:16px;color:#e8a030;font-weight:600}
+header .meta{font-size:11px;color:#888;text-align:right}
+.card{background:#1a1a1a;border:1px solid #282828;border-radius:4px;padding:10px 14px;margin-bottom:10px}
+.card h2{font-size:12px;color:#e8a030;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
+.row{display:flex;flex-wrap:wrap;gap:6px 18px}
+.row span{white-space:nowrap}
+.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
+.st{background:#222;border:1px solid #2a2a2a;border-radius:3px;padding:8px 10px;margin-bottom:6px;display:none}
+.st .sn{color:#e8a030;font-weight:600;font-size:12px}
+.st .si{font-size:11px;color:#999;margin-top:3px}
+.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
+.dg{background:#4a4}.da{background:#e8a030}.dr{background:#c44}
+.lbl{color:#666;font-size:11px}
+.val{color:#ddd}
+#rl .re{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #222}
+#rl .re:last-child{border:none}
+.btn{background:#e8a030;color:#111;border:none;padding:3px 10px;border-radius:3px;font:11px/1.4 inherit;cursor:pointer}
+.btn:hover{background:#f0b040}
+.bd{background:#c44}.bd:hover{background:#d55}
+.ms{display:flex;gap:8px;margin-top:6px;align-items:flex-start}
+.ms textarea{flex:1;background:#222;border:1px solid #333;color:#ccc;padding:4px 8px;border-radius:3px;font:11px/1.4 inherit;resize:vertical;min-height:60px}
+.ep{display:none;margin-top:6px;padding-top:6px;border-top:1px solid #333}
+.ep input,.ep textarea{width:100%;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px 6px;margin:2px 0 4px;font:11px inherit}
+.ep textarea{min-height:50px;font-size:10px;line-height:1.3;resize:vertical}
+.hdr{display:flex;justify-content:space-between;align-items:center}
+footer{border-top:1px solid #333;padding:8px 0;font-size:11px;color:#666;display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px;margin-top:8px}
+#overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:99;justify-content:center;align-items:center}
+#overlay.show{display:flex}
+#overlay .box{background:#1a1a1a;border:1px solid #e8a030;border-radius:6px;padding:20px 30px;text-align:center;font-size:13px;color:#ccc}
+#overlay .box .dot{width:10px;height:10px;margin:0 auto 10px}
+.tag{font-size:9px;padding:1px 5px;border-radius:2px;margin-left:6px;vertical-align:middle;color:#111;font-weight:600}
+.tag-gm{background:#e8a030}.tag-slave{background:#4a4}
+@media(max-width:700px){.grid{grid-template-columns:1fr}}
+</style>
+</head>
+<body>
+<div id="overlay"><div class="box"><span class="dot da"></span><div id="ol-msg">Connecting...</div></div></div>
+<div class="wrap">
+<header>
+<h1 id="devname">AES67 Node</h1>
+<div class="meta"><span id="ver">--</span><br><span id="ip">--</span></div>
+</header>
+<div class="card">
+<h2>PTP Status</h2>
+<div class="row">
+<span><span class="dot dr" id="pd"></span><span id="pl">--</span><span class="tag" id="pt" style="display:none"></span></span>
+<span><span class="lbl">GM </span><span class="val" id="pg">--</span></span>
+<span><span class="lbl">Offset </span><span class="val" id="po">--</span><span class="lbl"> ns</span></span>
+<span><span class="lbl">Jitter </span><span class="val" id="pj">--</span><span class="lbl"> ns</span></span>
+<span><span class="lbl">Delay </span><span class="val" id="py">--</span><span class="lbl"> ns</span></span>
+</div>
+</div>
+<div class="grid">
+<div class="card"><h2>Sources (TX)</h2><div id="sc"></div></div>
+<div class="card"><h2>Sinks (RX)</h2><div id="sk"></div></div>
+</div>
+<div class="card">
+<h2>Network Discovery</h2>
+<div id="rl"><span class="lbl">No remote sources discovered</span></div>
+</div>
+<div class="card">
+<h2>Manual Subscribe</h2>
+<div class="ms">
+<textarea id="sdp" placeholder="Paste SDP text here&#10;v=0&#10;o=- ..."></textarea>
+<button class="btn" onclick="mSub()">Subscribe</button>
+</div>
+</div>
+<footer>
+<span>Uptime: <span id="fu">--</span></span>
+<span>Heap: <span id="fh">--</span></span>
+<span>RTP: <span id="fr">--</span> pps</span>
+<span>Pkts: <span id="ft">--</span></span>
+<span>Lost: <span id="fl">--</span></span>
+</footer>
+</div>
+<script>
+(function(){
+var N=4,ws,rto,lRtp=0,lT=0;
+function g(id){return document.getElementById(id)}
+
+function fmt(n){return n!=null?Number(n).toLocaleString():"--"}
+function fmtUp(s){
+  if(s==null)return"--";
+  var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),r=s%60,p=[];
+  if(d)p.push(d+"d");p.push(h+"h");p.push(m+"m");p.push(r+"s");return p.join(" ");
+}
+function esc(s){return s?String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}
+function dc(l){return l==="locked"?"dg":l==="locking"||l==="acquiring"?"da":"dr"}
+function fmtTime(us){
+  if(!us)return"0";
+  if(us>=1000000)return(us/1000000).toFixed(1)+"s";
+  if(us>=1000){var ms=us/1000;return(ms%1?ms.toFixed(3).replace(/0+$/,''):ms.toFixed(0))+"ms"}
+  return us+"\u00b5s";
+}
+function fc(s){return(s.codec||"?")+" / "+(s.rate?fmt(s.rate)+"Hz":"?")+" / "+(s.ch||"?")+"ch / "+fmtTime(s.ptime)}
+
+/* Build all slot DOM once at init. Source slots and sink slots share structure
+   but sinks get edit panel + delete button. IDs follow the spec:
+   src-0-name, src-0-codec, src-0-tx, sink-0-name, sink-0-rx, sink-0-lost, etc. */
+function mkSlots(container,prefix,isSink){
+  var none=document.createElement("span");
+  none.className="lbl";none.id=prefix+"-none";
+  none.textContent=isSink?"No active sinks":"No active sources";
+  container.appendChild(none);
+  for(var i=0;i<N;i++){
+    var d=document.createElement("div");
+    d.className="st";d.id=prefix+"-slot-"+i;d.setAttribute("data-stream-id",String(i));
+    var hdr='<div class="hdr"><span class="sn"><span class="dot dr" id="'+prefix+"-"+i+'-dot"></span><span id="'+prefix+"-"+i+'-name">--</span></span><span>';
+    if(isSink){
+      hdr+='<button class="btn" style="font-size:10px;padding:1px 6px;margin-right:4px" onclick="TE(\''+prefix+"',"+i+')">edit</button>';
+      hdr+='<button class="btn bd" style="font-size:10px;padding:1px 6px" onclick="dS('+i+')">x</button>';
+    }else{
+      hdr+='<button class="btn" style="font-size:10px;padding:1px 6px" onclick="TE(\''+prefix+"',"+i+')">info</button>';
+    }
+    hdr+='</span></div>';
+    hdr+='<div class="si" id="'+prefix+"-"+i+'-codec">--</div>';
+    if(isSink){
+      hdr+='<div class="si">RX: <span id="sink-'+i+'-rx">--</span> pkts | Lost: <span id="sink-'+i+'-lost">--</span> | Jitter: <span id="sink-'+i+'-jitter">--</span> us</div>';
+      hdr+='<div class="ep" id="edit-sink-'+i+'"><div class="lbl">Name:</div><input id="edit-sink-name-'+i+'" value=""><div class="lbl">SDP (paste to re-subscribe):</div><textarea id="edit-sink-sdp-'+i+'" placeholder="Paste SDP..."></textarea><button class="btn" onclick="rS('+i+')">Re-subscribe</button></div>';
+    }else{
+      hdr+='<div class="si">TX: <span id="src-'+i+'-tx">--</span> pkts</div>';
+      hdr+='<div class="ep" id="edit-src-'+i+'"><div class="lbl">Source ID: '+i+'</div></div>';
+    }
+    d.innerHTML=hdr;
+    container.appendChild(d);
+  }
+}
+
+/* Init after DOM ready (gzip delivery may race with parser) */
+function init(){
+  mkSlots(g("sc"),"src",false);
+  mkSlots(g("sk"),"sink",true);
+  fetch("/api/info").then(function(r){return r.json()}).then(function(d){
+    if(d.name)g("devname").textContent=d.name;
+    if(d.version)g("ver").textContent="v"+d.version;
+    if(d.ip)g("ip").textContent=d.ip;
+  }).catch(function(){});
+  wsConn();
+}
+if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);
+else init();
+
+function updateSlots(prefix,arr){
+  var ids={},i;
+  if(arr)for(i=0;i<arr.length;i++)ids[arr[i].id]=arr[i];
+  var any=false;
+  for(i=0;i<N;i++){
+    var slot=g(prefix+"-slot-"+i),data=ids[i];
+    if(data){
+      any=true;
+      slot.style.display="block";
+      var on=prefix==="src"?true:data.active;
+      g(prefix+"-"+i+"-dot").className="dot "+(on?"dg":"dr");
+      g(prefix+"-"+i+"-name").textContent=data.name||"--";
+      g(prefix+"-"+i+"-codec").textContent=fc(data);
+      if(prefix==="src"){
+        g("src-"+i+"-tx").textContent=fmt(data.tx);
+      }else{
+        g("sink-"+i+"-rx").textContent=fmt(data.rx);
+        g("sink-"+i+"-lost").textContent=fmt(data.lost);
+        g("sink-"+i+"-jitter").textContent=fmt(data.jitter);
+        var ni=g("edit-sink-name-"+i);
+        if(ni&&document.activeElement!==ni)ni.value=data.name||"";
+      }
+    }else{
+      slot.style.display="none";
+      var ep=g("edit-"+prefix+"-"+i);
+      if(ep)ep.style.display="none";
+    }
+  }
+  g(prefix+"-none").style.display=any?"none":"";
+}
+
+function renderRemotes(arr){
+  var c=g("rl");
+  if(!arr||!arr.length){c.innerHTML='<span class="lbl">No remote sources discovered</span>';return}
+  var h="";
+  for(var i=0;i<arr.length;i++){
+    var r=arr[i];
+    var info=r.codec?(esc(r.codec)+' / '+fmt(r.rate)+'Hz / '+r.ch+'ch / '+fmtTime(r.ptime)):'';
+    h+='<div class="re"><span>'+esc(r.name)+' <span class="lbl">('+esc(r.origin)+')</span>'+(info?' <span class="lbl">'+info+'</span>':'')+'</span><button class="btn" onclick="sR(\''+esc(r.origin)+"','"+esc(r.name)+'\')">Subscribe</button></div>';
+  }
+  c.innerHTML=h;
+}
+
+function onMsg(e){
+  var d;try{d=JSON.parse(e.data)}catch(x){return}
+  if(d.t!=="status")return;
+  var p=d.ptp||{};
+  g("pd").className="dot "+dc(p.lock);
+  g("pl").textContent=p.lock||"Unknown";
+  g("pg").textContent=p.gm||"--";
+  var pt=g("pt");
+  if(p.is_gm){pt.textContent="GRANDMASTER";pt.className="tag tag-gm";pt.style.display=""}
+  else if(p.lock==="locked"){pt.textContent="SLAVE";pt.className="tag tag-slave";pt.style.display=""}
+  else{pt.style.display="none"}
+  g("po").textContent=fmt(p.offset);
+  g("pj").textContent=fmt(p.jitter);
+  g("py").textContent=fmt(p.delay);
+  updateSlots("src",d.sources);
+  updateSlots("sink",d.sinks);
+  renderRemotes(d.sap_remotes);
+  g("fu").textContent=fmtUp(d.uptime);
+  g("fh").textContent=fmt(d.heap_free)+" B";
+  var eth=d.eth||{},now=Date.now();
+  if(lT>0&&eth.rtp!=null){var dt=(now-lT)/1000;if(dt>0.1)g("fr").textContent=fmt(Math.round((eth.rtp-lRtp)/dt))}
+  lRtp=eth.rtp||0;lT=now;
+  g("ft").textContent=fmt(eth.total);
+  g("fl").textContent=fmt(eth.lost);
+}
+
+var backoff=1000;
+function showOl(msg){var o=g("overlay");o.classList.add("show");g("ol-msg").textContent=msg}
+function hideOl(){g("overlay").classList.remove("show")}
+
+function wsConn(){
+  showOl("Connecting...");
+  var l=location;
+  ws=new WebSocket((l.protocol==="https:"?"wss://":"ws://")+l.host+"/ws");
+  ws.onopen=function(){backoff=1000;hideOl()};
+  ws.onmessage=onMsg;
+  ws.onclose=function(){
+    var sec=Math.round(backoff/1000);
+    showOl("Disconnected. Retrying in "+sec+"s...");
+    rto=setTimeout(wsConn,backoff);
+    backoff=Math.min(backoff*1.5,10000);
+  };
+  ws.onerror=function(){ws.close()};
+}
+
+/* Globals for onclick handlers */
+window.TE=function(t,id){var e=g("edit-"+t+"-"+id);if(e)e.style.display=(!e.style.display||e.style.display==="none")?"block":"none"};
+window.rS=function(id){
+  var n=g("edit-sink-name-"+id),s=g("edit-sink-sdp-"+id);
+  if(!s||!s.value.trim()){alert("Paste SDP text to re-subscribe");return}
+  fetch("/api/sinks/"+id,{method:"DELETE"}).then(function(){
+    return fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n?n.value:"Sink",sdp:s.value})});
+  });
+};
+window.sR=function(o,n){fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,origin:o})})};
+window.dS=function(id){fetch("/api/sinks/"+id,{method:"DELETE"})};
+window.mSub=function(){
+  var v=g("sdp").value.trim();if(!v)return;
+  fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"Manual",sdp:v})}).then(function(r){if(r.ok)g("sdp").value=""});
+};
+
+})();
+</script>
+</body>
+</html>

BIN
components/aes67/web/index.html.gz


+ 6 - 0
main/CMakeLists.txt

@@ -0,0 +1,6 @@
+idf_component_register(
+    SRCS "main.c"
+    INCLUDE_DIRS "."
+    PRIV_REQUIRES aes67 esp_eth esp_netif esp_event nvs_flash
+                  esp_driver_gpio esp_driver_i2c esp-dsp esp_mm
+)

+ 39 - 0
main/board_config.h

@@ -0,0 +1,39 @@
+/*
+ * board_config.h — hardware pin map for the ESP32-P4 Dante/AES67 endpoint.
+ *
+ * Reference target: DatanoiseTV ESP32-P4-NANO (Ethernet via IP101 GRI PHY on
+ * RMII, onboard ES8311 codec + NS4150B amp). Values mirror the pin table in
+ * README.md. If the deployed board differs, update BOTH this file and the
+ * README table together (see CLAUDE.md).
+ *
+ * NOTE: some I2S functions have alternate "onboard" pin options noted in the
+ * README. The active values below use the primary column; onboard variants
+ * are listed in comments for quick swapping.
+ */
+#pragma once
+
+/* ---- Ethernet (RMII to IP101 GRI PHY) ---- */
+#define BOARD_ETH_MDC_GPIO      31
+#define BOARD_ETH_MDIO_GPIO     52
+#define BOARD_ETH_REF_CLK_GPIO  50  /* external 50 MHz clock */
+#define BOARD_ETH_PHY_RST_GPIO  51
+#define BOARD_ETH_PHY_ADDR      1
+
+/* ---- I2S audio to ES8311 codec ---- */
+#define BOARD_I2S_MCLK_GPIO     13  /* 18.432 MHz via APLL */
+#define BOARD_I2S_BCLK_GPIO     36  /* onboard alt: 12 */
+#define BOARD_I2S_LRCLK_GPIO    33  /* onboard alt: 10 */
+#define BOARD_I2S_DOUT_GPIO     32  /* onboard alt: 9  */
+#define BOARD_I2S_DIN_GPIO      11  /* codec ADC input */
+
+/* ---- Power amp enable (NS4150B) ---- */
+#define BOARD_PA_ENABLE_GPIO    53  /* active high */
+
+/* ---- I2C control bus (ES8311) ---- */
+#define BOARD_I2C_SCL_GPIO      8
+#define BOARD_I2C_SDA_GPIO      7
+
+/* ---- Default audio format ---- */
+#define BOARD_AUDIO_SAMPLE_RATE 48000
+#define BOARD_AUDIO_BIT_DEPTH   24  /* AES67 L24 */
+#define BOARD_AUDIO_CHANNELS    2

+ 7 - 0
main/idf_component.yml

@@ -0,0 +1,7 @@
+dependencies:
+  idf:
+    version: ">=5.5.0"
+  espressif/esp-dsp:
+    version: "*"
+  espressif/es8311:
+    version: "*"

+ 660 - 0
main/main.c

@@ -0,0 +1,660 @@
+/*
+ * main.c — application entry point for the ESP32-P4 Dante/AES67 endpoint.
+ *
+ * Phase 1 target is AES67 interop. This file is adapted from the example
+ * application in DatanoiseTV/aes67-esp32p4 (Apache-2.0) — the AES67 logic is
+ * kept as-is; the hardware pin map is sourced from board_config.h so there is
+ * a single place to retarget the board (see CLAUDE.md / README.md).
+ *
+ * SPDX-FileCopyrightText: 2026 DatanoiseTV (original example)
+ * SPDX-FileCopyrightText: 2026 Benjamin Harris (adaptation)
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Flow: init Ethernet (IP101 RMII PHY), wait for IP, bring up the AES67 stack,
+ * auto-subscribe to the first SAP-discovered source and create a matching
+ * source stream, capturing/streaming L24 audio via the ES8311 codec.
+ */
+
+#include <string.h>
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/event_groups.h"
+
+#include "esp_log.h"
+#include "esp_err.h"
+#include "esp_event.h"
+#include "esp_netif.h"
+#include "nvs_flash.h"
+#include "driver/gpio.h"
+
+#include "esp_eth.h"
+#include "esp_eth_mac.h"
+#include "esp_eth_phy.h"
+
+#include "aes67.h"
+#include "aes67_config.h"
+#include "aes67_convert.h"
+#include "aes67_session.h"
+#include "aes67_rtp.h"
+#include "aes67_sap.h"
+#include "aes67_audio.h"
+#include "aes67_webui.h"
+#include "dsps_tone_gen.h"
+#include "driver/i2c.h"
+#include "freertos/stream_buffer.h"
+#include "esp_cache.h"
+#include "esp_ldo_regulator.h"
+#include "driver/i2s_std.h"
+#include "es8311.h"
+
+#include "board_config.h"
+
+/* ---- Map the board pin table (board_config.h) onto the names the AES67
+ *      example code below expects. Edit board_config.h, not these. ---- */
+#define ETH_MDC_GPIO        BOARD_ETH_MDC_GPIO
+#define ETH_MDIO_GPIO       BOARD_ETH_MDIO_GPIO
+#define ETH_REF_CLK_GPIO    BOARD_ETH_REF_CLK_GPIO
+#define ETH_PHY_RST_GPIO    BOARD_ETH_PHY_RST_GPIO
+#define ETH_PHY_ADDR        BOARD_ETH_PHY_ADDR
+
+#define I2S_MCLK_GPIO       BOARD_I2S_MCLK_GPIO
+#define I2S_SCLK_GPIO       BOARD_I2S_BCLK_GPIO
+#define I2S_LRCK_GPIO       BOARD_I2S_LRCLK_GPIO
+#define I2S_DOUT_GPIO       BOARD_I2S_DOUT_GPIO
+#define I2S_DIN_GPIO        BOARD_I2S_DIN_GPIO
+
+#define PA_CTRL_GPIO        BOARD_PA_ENABLE_GPIO
+
+#define I2C_SCL_GPIO        BOARD_I2C_SCL_GPIO
+#define I2C_SDA_GPIO        BOARD_I2C_SDA_GPIO
+#define I2C_PORT            I2C_NUM_0
+#define I2C_CLK_HZ          100000
+#define ES8311_ADDR         ES8311_ADDRESS_0    /* 0x18, CE pin low */
+
+/* Session handle for SAP callback */
+static aes67_session_handle_t s_session = NULL;
+static bool s_sink_added = false;
+
+/* Stream params for the Ethernet RTP hook (defined below, used by SAP callback) */
+extern uint8_t s_hook_word_length;
+extern uint8_t s_hook_channels;
+
+/* SAP discovery callback: auto-subscribe to discovered remote sources */
+static void sap_discovery_cb(bool is_announce,
+                              const aes67_sap_remote_source_t *source,
+                              void *user_data)
+{
+    if (!is_announce || !source || s_sink_added || !s_session) {
+        return;
+    }
+
+    /* Subscribe to any discovered source (first one wins) */
+
+    ESP_LOGI("main", "Auto-subscribing to \"%s\" from SAP", source->name);
+
+    uint8_t sink_id = 0;
+    esp_err_t err = aes67_session_add_sink(s_session, source->name,
+                                            source->sdp, &sink_id);
+    if (err == ESP_OK) {
+        s_sink_added = true;
+        /* Update Ethernet hook params from the SDP */
+        aes67_sink_t sink;
+        if (aes67_session_get_sink(s_session, sink_id, &sink) == ESP_OK) {
+            s_hook_word_length = sink.rtp_config.word_length;
+            s_hook_channels = sink.rtp_config.channels;
+            ESP_LOGI("main", "Hook: wl=%u ch=%u rate=%lu ptime=%u",
+                     s_hook_word_length, s_hook_channels,
+                     (unsigned long)sink.rtp_config.sample_rate,
+                     sink.rtp_config.packet_time_us);
+
+            /* Create a matching source with same codec/channels/ptime */
+            aes67_session_set_packet_time(s_session, sink.rtp_config.packet_time_us);
+            uint8_t src_id = 0;
+            esp_err_t src_err = aes67_session_add_source(
+                s_session, "ESP32-P4 AES67",
+                sink.rtp_config.channels, sink.rtp_config.codec, &src_id);
+            if (src_err == ESP_OK) {
+                ESP_LOGI("main", "Source matched to sink: %uch %s %uus",
+                         sink.rtp_config.channels,
+                         aes67_codec_to_str(sink.rtp_config.codec),
+                         sink.rtp_config.packet_time_us);
+            }
+        }
+        ESP_LOGI("main", "Sink added (id=%u) -- receiving \"%s\"",
+                 sink_id, source->name);
+    } else {
+        ESP_LOGW("main", "Failed to add sink for \"%s\": %s",
+                 source->name, esp_err_to_name(err));
+    }
+}
+
+static const char *TAG = "main";
+
+/* Event group bit set when the Ethernet interface obtains an IP */
+#define ETH_GOT_IP_BIT  BIT0
+
+static EventGroupHandle_t s_eth_event_group;
+
+/* Ethernet link event handler */
+static void eth_event_handler(void *arg, esp_event_base_t event_base,
+                              int32_t event_id, void *event_data)
+{
+    switch (event_id) {
+    case ETHERNET_EVENT_CONNECTED:
+        ESP_LOGI(TAG, "ethernet link up");
+        break;
+    case ETHERNET_EVENT_DISCONNECTED:
+        ESP_LOGW(TAG, "ethernet link down");
+        break;
+    case ETHERNET_EVENT_START:
+        ESP_LOGI(TAG, "ethernet started");
+        break;
+    case ETHERNET_EVENT_STOP:
+        ESP_LOGI(TAG, "ethernet stopped");
+        break;
+    default:
+        break;
+    }
+}
+
+/* IP acquisition event handler */
+static void ip_event_handler(void *arg, esp_event_base_t event_base,
+                             int32_t event_id, void *event_data)
+{
+    if (event_id == IP_EVENT_ETH_GOT_IP) {
+        ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
+        ESP_LOGI(TAG, "got IP: " IPSTR, IP2STR(&event->ip_info.ip));
+        xEventGroupSetBits(s_eth_event_group, ETH_GOT_IP_BIT);
+    }
+}
+
+/* --- Ethernet RTP hook: intercept RTP frames before lwIP --- */
+
+static esp_err_t (*s_original_input)(esp_eth_handle_t, uint8_t*, uint32_t, void*, void*) = NULL;
+static void *s_original_priv = NULL;
+static i2s_chan_handle_t s_hook_tx_chan = NULL;
+static int32_t *s_hook_tmp32 = NULL;
+static int16_t *s_hook_i16 = NULL;
+uint32_t s_hook_pkt_count = 0;             /* Visible to webui for stats */
+uint32_t s_hook_drop_count = 0;
+uint32_t s_hook_fwd_count = 0;
+uint32_t s_hook_total_frames = 0;
+static uint16_t s_hook_last_seq = 0;
+static uint32_t s_hook_seq_gaps = 0;
+uint32_t s_hook_seq_lost = 0;
+StreamBufferHandle_t s_hook_sbuf = NULL;  /* Global: shared with playback task */
+uint8_t s_hook_word_length = 3;          /* Default L24. Updated from SDP. */
+uint8_t s_hook_channels = 2;             /* Default stereo. Updated from SDP. */
+
+/* Called for EVERY Ethernet frame, before lwIP.
+ * RTP multicast on port 5004: process + free. Everything else: forward to lwIP. */
+static esp_err_t IRAM_ATTR eth_rtp_hook(esp_eth_handle_t eth_handle,
+                                          uint8_t *buffer, uint32_t length,
+                                          void *priv, void *info)
+{
+    s_hook_total_frames++;
+
+    /* Quick reject: minimum Ethernet+IP+UDP+RTP header = 54 bytes */
+    if (length < 54 || !s_hook_tx_chan) goto forward;
+
+    /* EtherType must be IPv4 */
+    if (buffer[12] != 0x08 || buffer[13] != 0x00) goto forward;
+
+    /* IP protocol must be UDP (17) */
+    uint8_t ihl = (buffer[14] & 0x0F) * 4;
+    if (buffer[14 + 9] != 17) goto forward;
+
+    /* Must be IPv4 multicast destination (224.0.0.0/4) */
+    if ((buffer[14 + 16] & 0xF0) != 0xE0) goto forward;
+
+    /* UDP destination port must be 5004 */
+    int udp_off = 14 + ihl;
+    uint16_t dst_port = (buffer[udp_off + 2] << 8) | buffer[udp_off + 3];
+    if (dst_port != 5004) goto forward;
+
+    /* RTP version must be 2 */
+    int rtp_off = udp_off + 8;
+    if ((buffer[rtp_off] >> 6) != 2) goto forward;
+
+    /* Track RTP sequence numbers to detect packet loss */
+    {
+        uint16_t seq = (buffer[rtp_off + 2] << 8) | buffer[rtp_off + 3];
+        if (s_hook_pkt_count > 0) {
+            uint16_t expected = s_hook_last_seq + 1;
+            if (seq != expected) {
+                int16_t gap = (int16_t)(seq - expected);
+                if (gap > 0 && gap < 100) {
+                    s_hook_seq_gaps++;
+                    s_hook_seq_lost += gap;
+                }
+            }
+        }
+        s_hook_last_seq = seq;
+    }
+
+    /* Extract RTP payload */
+    uint8_t cc = buffer[rtp_off] & 0x0F;
+    int payload_off = rtp_off + 12 + cc * 4;
+
+    /* Handle RTP header extension */
+    if (buffer[rtp_off] & 0x10) {
+        if ((int)length < payload_off + 4) goto forward;
+        uint16_t ext_len = (buffer[payload_off + 2] << 8) | buffer[payload_off + 3];
+        payload_off += 4 + ext_len * 4;
+    }
+
+    if (payload_off >= (int)length) goto forward;
+
+    const uint8_t *payload = buffer + payload_off;
+    int payload_len = length - payload_off;
+
+    /* ESP-IDF Ethernet driver already does M2C cache sync in
+     * emac_esp_dma_receive_frame(). No additional sync needed. */
+
+    /* Handle RTP padding (P bit) */
+    if (buffer[rtp_off] & 0x20) {
+        uint8_t pad_len = buffer[length - 1];
+        payload_len -= pad_len;
+        if (payload_len <= 0) goto forward;
+    }
+
+    /* Use UDP length field instead of Ethernet frame length to avoid
+     * including Ethernet FCS or padding bytes in the audio data.
+     * UDP length = header(8) + payload. */
+    uint16_t udp_len = (buffer[udp_off + 4] << 8) | buffer[udp_off + 5];
+    int rtp_total = udp_len - 8;  /* UDP payload = RTP header + RTP payload */
+    int rtp_payload = rtp_total - (payload_off - rtp_off);
+    if (rtp_payload > 0 && rtp_payload < payload_len) {
+        payload_len = rtp_payload;  /* Trim to actual RTP payload */
+    }
+
+    /* Use word length and channel count from SDP (set when sink is added) */
+    int wl = s_hook_word_length;
+    int ch = s_hook_channels;
+    int frame_bytes = ch * wl;
+    int frames = payload_len / frame_bytes;
+    if (frames <= 0 || frames > AES67_MAX_TIC_FRAMES) goto forward;
+
+    /* Log first packet details */
+    if (s_hook_pkt_count == 0) {
+        ESP_DRAM_LOGI("eth_hook", "FIRST PKT: payload=%d bytes, wl=%d, ch=%d, frames=%d, "
+                      "write_bytes=%d", payload_len, wl, ch, frames,
+                      frames * ch * (int)sizeof(int32_t));
+    }
+
+    /* Convert to int32 -- stereo passthrough, no mono mix */
+    aes67_convert_from_net(payload, s_hook_tmp32, frames, ch, wl);
+
+    /* Write int32 stereo directly to stream buffer (read by DMA ISR) */
+    extern void aes67_audio_slot_write(const void *data, uint32_t len);
+    aes67_audio_slot_write(s_hook_tmp32, frames * ch * sizeof(int32_t));
+
+    s_hook_pkt_count++;
+
+    /* Rate-limited loss reporting: max once per second */
+    static uint32_t s_last_seq_lost = 0;
+    static uint32_t s_last_loss_log_pkt = 0;
+    if (s_hook_seq_lost != s_last_seq_lost &&
+        (s_hook_pkt_count - s_last_loss_log_pkt) > 8000) {
+        ESP_LOGW("eth_hook", "SEQ LOSS: %lu gaps, %lu lost (total %lu pkts)",
+                 (unsigned long)s_hook_seq_gaps,
+                 (unsigned long)s_hook_seq_lost,
+                 (unsigned long)s_hook_pkt_count);
+        s_last_seq_lost = s_hook_seq_lost;
+        s_last_loss_log_pkt = s_hook_pkt_count;
+    }
+
+    free(buffer);  /* We consumed the frame */
+    return ESP_OK;
+
+forward:
+    s_hook_fwd_count++;
+    /* Not RTP -- forward to lwIP via esp_netif_receive */
+    if (s_original_priv) {
+        return esp_netif_receive((esp_netif_t *)s_original_priv, buffer, length, NULL);
+    }
+    free(buffer);
+    return ESP_OK;
+}
+
+/*
+ * Initialize the IP101 Ethernet PHY over RMII.
+ * The ESP32-P4-NANO uses an external 50MHz crystal for the RMII reference
+ * clock, so we configure EMAC_CLK_EXT_IN on GPIO50.
+ */
+static esp_err_t ethernet_init(esp_eth_handle_t *out_eth_handle)
+{
+    /* EMAC configuration with RMII pin mapping */
+    eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG();
+    emac_config.smi_gpio.mdc_num = ETH_MDC_GPIO;
+    emac_config.smi_gpio.mdio_num = ETH_MDIO_GPIO;
+    emac_config.interface = EMAC_DATA_INTERFACE_RMII;
+    emac_config.clock_config.rmii.clock_mode = EMAC_CLK_EXT_IN;
+    emac_config.clock_config.rmii.clock_gpio = ETH_REF_CLK_GPIO;
+
+    eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG();
+    mac_config.flags = ETH_MAC_FLAG_PIN_TO_CORE;  /* Pin to core 0 */
+    mac_config.rx_task_prio = 24;  /* Highest: above lwIP (23), feeds audio ISR directly */
+    esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&emac_config, &mac_config);
+    if (!mac) {
+        ESP_LOGE(TAG, "failed to create EMAC");
+        return ESP_FAIL;
+    }
+
+    /* IP101 PHY at address 1, with hardware reset on GPIO51 */
+    eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG();
+    phy_config.phy_addr = ETH_PHY_ADDR;
+    phy_config.reset_gpio_num = ETH_PHY_RST_GPIO;
+    esp_eth_phy_t *phy = esp_eth_phy_new_ip101(&phy_config);
+    if (!phy) {
+        ESP_LOGE(TAG, "failed to create IP101 PHY");
+        return ESP_FAIL;
+    }
+
+    /* Install the Ethernet driver */
+    esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, phy);
+    esp_eth_handle_t eth_handle = NULL;
+    esp_err_t ret = esp_eth_driver_install(&eth_config, &eth_handle);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "ethernet driver install failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    /* Create the netif with key "ETH_0" so the L2 TAP interface used by
+     * ptpd can find it. The default ESP_NETIF_DEFAULT_ETH uses "ETH_DEF"
+     * which does not match what ptpd_start("ETH_0") expects. */
+    esp_netif_inherent_config_t base_cfg = ESP_NETIF_INHERENT_DEFAULT_ETH();
+    base_cfg.if_key = "ETH_0";
+    esp_netif_config_t netif_config = {
+        .base = &base_cfg,
+        .stack = ESP_NETIF_NETSTACK_DEFAULT_ETH,
+    };
+    esp_netif_t *eth_netif = esp_netif_new(&netif_config);
+    if (!eth_netif) {
+        ESP_LOGE(TAG, "failed to create ethernet netif");
+        return ESP_FAIL;
+    }
+
+    /* Attach the driver to the netif via the default glue layer */
+    esp_eth_netif_glue_handle_t glue = esp_eth_new_netif_glue(eth_handle);
+    ret = esp_netif_attach(eth_netif, glue);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "failed to attach ethernet to netif: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    /* Register event handlers for link and IP events */
+    ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID,
+                                               &eth_event_handler, NULL));
+    ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP,
+                                               &ip_event_handler, NULL));
+
+    /* Ethernet RTP hook: intercept RTP at MAC level before lwIP */
+    s_original_priv = eth_netif;
+    /* 192 frames * max channels covers the AES67 practical max (4ms at 48kHz) */
+    s_hook_tmp32 = heap_caps_malloc(
+        192 * AES67_MAX_CHANNELS_PER_STREAM * sizeof(int32_t),
+        MALLOC_CAP_INTERNAL);
+    s_hook_i16 = heap_caps_malloc(
+        192 * AES67_MAX_CHANNELS_PER_STREAM * sizeof(int16_t),
+        MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+    esp_eth_update_input_path_info(eth_handle, eth_rtp_hook, eth_netif);
+    ESP_LOGI(TAG, "Ethernet RTP hook installed");
+
+    /* Enable Ethernet flow control to prevent frame drops under load.
+     * Without this, the EMAC drops ~8% of multicast frames at high
+     * packet rates (1000 pps) because the DMA can't keep up. */
+    bool flow_ctrl = true;
+    esp_eth_ioctl(eth_handle, ETH_CMD_S_FLOW_CTRL, &flow_ctrl);
+
+    /* Start Ethernet */
+    ret = esp_eth_start(eth_handle);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "ethernet start failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    *out_eth_handle = eth_handle;
+    return ESP_OK;
+}
+
+/* Enable the power amplifier on the ES8311 output stage */
+static void pa_ctrl_enable(void)
+{
+    gpio_config_t io_conf = {
+        .pin_bit_mask = (1ULL << PA_CTRL_GPIO),
+        .mode = GPIO_MODE_OUTPUT,
+        .pull_up_en = GPIO_PULLUP_DISABLE,
+        .pull_down_en = GPIO_PULLDOWN_DISABLE,
+        .intr_type = GPIO_INTR_DISABLE,
+    };
+    gpio_config(&io_conf);
+    gpio_set_level(PA_CTRL_GPIO, 1);
+    ESP_LOGI(TAG, "PA enabled (GPIO%d high)", PA_CTRL_GPIO);
+}
+
+/*
+ * Initialize the ES8311 audio codec via I2C.
+ * Configures it for 48kHz 24-bit I2S operation with both DAC (playback)
+ * and ADC (capture) enabled.
+ */
+static esp_err_t es8311_codec_init(void)
+{
+    /* Initialize I2C master bus */
+    i2c_config_t i2c_cfg = {
+        .mode = I2C_MODE_MASTER,
+        .sda_io_num = I2C_SDA_GPIO,
+        .scl_io_num = I2C_SCL_GPIO,
+        .sda_pullup_en = GPIO_PULLUP_ENABLE,
+        .scl_pullup_en = GPIO_PULLUP_ENABLE,
+        .master.clk_speed = I2C_CLK_HZ,
+    };
+    esp_err_t ret = i2c_param_config(I2C_PORT, &i2c_cfg);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "I2C param config failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    ret = i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "I2C driver install failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    /* Create ES8311 codec handle */
+    es8311_handle_t codec = es8311_create(I2C_PORT, ES8311_ADDR);
+    if (!codec) {
+        ESP_LOGE(TAG, "ES8311 create failed");
+        return ESP_FAIL;
+    }
+
+    /* Configure clock: MCLK from MCLK pin, 48kHz sample rate.
+     * I2S uses MCLK_MULTIPLE_384 (matching official ESP-IDF example).
+     * MCLK = 48000 * 384 = 18.432 MHz */
+    es8311_clock_config_t clk_cfg = {
+        .mclk_inverted = false,
+        .sclk_inverted = false,
+        .mclk_from_mclk_pin = true,
+        .mclk_frequency = 48000 * 384,     /* 18.432 MHz */
+        .sample_frequency = 48000,
+    };
+
+    /* 32-bit resolution -- int32 maps directly, no truncation */
+    ret = es8311_init(codec, &clk_cfg, ES8311_RESOLUTION_32, ES8311_RESOLUTION_32);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "ES8311 init failed: %s", esp_err_to_name(ret));
+        return ret;
+    }
+
+    ret = es8311_voice_volume_set(codec, 70, NULL);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "ES8311 volume set failed: %s", esp_err_to_name(ret));
+    }
+
+    /* Unmute DAC output */
+    ret = es8311_voice_mute(codec, false);
+    if (ret != ESP_OK) {
+        ESP_LOGW(TAG, "ES8311 unmute failed: %s", esp_err_to_name(ret));
+    }
+
+    /* Configure microphone: analog mic, 24dB gain */
+    es8311_microphone_config(codec, false);
+    es8311_microphone_gain_set(codec, ES8311_MIC_GAIN_24DB);
+
+    /* Dump ES8311 register state for debugging */
+    es8311_register_dump(codec);
+
+    ESP_LOGI(TAG, "ES8311 codec initialized (48kHz, 16-bit, MCLK=18.432MHz, vol=100)");
+    return ESP_OK;
+}
+
+void app_main(void)
+{
+    /* Initialize NVS -- required by some ESP-IDF components internally */
+    esp_err_t ret = nvs_flash_init();
+    if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
+        ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+        ESP_ERROR_CHECK(nvs_flash_erase());
+        ret = nvs_flash_init();
+    }
+    ESP_ERROR_CHECK(ret);
+
+    /* Configure LDO4 to 3.3V for SDIO/SPI power domain.
+     * Must be done early before any peripherals on this rail. */
+    esp_ldo_channel_handle_t ldo4 = NULL;
+    esp_ldo_channel_config_t ldo_cfg = {
+        .chan_id = 4,
+        .voltage_mv = 3300,
+    };
+    if (esp_ldo_acquire_channel(&ldo_cfg, &ldo4) == ESP_OK) {
+        ESP_LOGI(TAG, "LDO4 set to 3.3V");
+    } else {
+        ESP_LOGW(TAG, "LDO4 config failed (may already be set)");
+    }
+
+    /* Create the default event loop used by Ethernet and AES67 events */
+    ESP_ERROR_CHECK(esp_event_loop_create_default());
+
+    /* Initialize the TCP/IP stack */
+    ESP_ERROR_CHECK(esp_netif_init());
+
+    /* Set up the event group used to synchronize on IP acquisition */
+    s_eth_event_group = xEventGroupCreate();
+
+    /* Bring up Ethernet with the IP101 PHY */
+    esp_eth_handle_t eth_handle = NULL;
+    ESP_ERROR_CHECK(ethernet_init(&eth_handle));
+
+    /* Block until we have an IP address. The AES67 stack needs network
+     * connectivity for PTP, RTP multicast and SAP. */
+    ESP_LOGI(TAG, "waiting for IP address...");
+    xEventGroupWaitBits(s_eth_event_group, ETH_GOT_IP_BIT,
+                        pdFALSE, pdTRUE, portMAX_DELAY);
+
+    /* Enable the power amplifier before starting audio */
+    pa_ctrl_enable();
+
+    /* ES8311 codec init is deferred until AFTER the AES67 I2S driver starts.
+     * The ES8311 needs MCLK to be running during initialization. */
+
+    /* Build the AES67 node configuration with our I2S pins and codec settings */
+    aes67_config_t aes67_cfg = AES67_CONFIG_DEFAULT();
+    strlcpy(aes67_cfg.node_name, "ESP32-P4 AES67", sizeof(aes67_cfg.node_name));
+
+    aes67_cfg.net.eth_handle = eth_handle;
+
+    aes67_cfg.i2s_pins.mck_gpio  = I2S_MCLK_GPIO;
+    aes67_cfg.i2s_pins.bck_gpio  = I2S_SCLK_GPIO;
+    aes67_cfg.i2s_pins.ws_gpio   = I2S_LRCK_GPIO;
+    aes67_cfg.i2s_pins.dout_gpio = I2S_DOUT_GPIO;
+    aes67_cfg.i2s_pins.din_gpio  = I2S_DIN_GPIO;
+
+    aes67_cfg.audio.sample_rate   = 48000;
+    aes67_cfg.audio.channels      = 2;
+    aes67_cfg.audio.codec         = AES67_CODEC_L24;
+    aes67_cfg.audio.word_length   = 3;       /* 24-bit = 3 bytes */
+    aes67_cfg.audio.packet_time_us = 1000;   /* 1ms packet time per AES67 */
+
+    /* Initialize the AES67 node */
+    aes67_node_handle_t node = NULL;
+    ESP_ERROR_CHECK(aes67_node_init(&aes67_cfg, &node));
+
+    /* Create stream buffer BEFORE starting the node. The DMA ISR callback
+     * reads directly from this buffer, bypassing the playback task.
+     * 30 packets (~120ms at 4ms ptime) for jitter absorption.
+     * Trigger=1: ISR always reads whatever is available. */
+    {
+        uint32_t pkt_bytes = 192 * aes67_cfg.audio.channels * sizeof(int32_t);
+        s_hook_sbuf = xStreamBufferCreate(pkt_bytes * 30, 1);
+
+        /* Wire the stream buffer to the audio driver's DMA ISR */
+        aes67_audio_handle_t audio = NULL;
+        aes67_node_get_audio(node, &audio);
+        aes67_audio_set_isr_stream_buf(audio, s_hook_sbuf);
+        /* Also register it as the node's playout source for the (non-ISR)
+         * playback-task fallback path. */
+        aes67_node_set_playout_buf(node, s_hook_sbuf);
+        /* Enable I2S RX capture for source (TX) streams */
+        aes67_audio_init_capture(audio);
+        s_hook_tx_chan = aes67_audio_get_tx_chan(audio);
+        ESP_LOGI(TAG, "Ethernet hook -> stream buffer -> DMA ISR (zero-overhead)");
+        ESP_LOGI(TAG, "I2S RX capture -> stream buffer -> RTP TX (ISR-driven)");
+    }
+
+    /* Start PTP sync, RTP engine, SAP announcements and audio I/O.
+     * This enables I2S which starts MCLK generation. The DMA ISR
+     * callback is registered during audio start. */
+    ESP_ERROR_CHECK(aes67_node_start(node));
+
+    /* Initialize ES8311 codec AFTER I2S is running (MCLK must be active).
+     * This is the critical init order discovered through testing. */
+    esp_err_t codec_ret = es8311_codec_init();
+    if (codec_ret != ESP_OK) {
+        ESP_LOGE(TAG, "ES8311 codec init FAILED: %s", esp_err_to_name(codec_ret));
+    } else {
+        ESP_LOGI(TAG, "ES8311 codec initialized (MCLK running)");
+    }
+
+    /* Obtain the session manager handle from the node so we can add streams */
+    aes67_session_handle_t session = NULL;
+    ESP_ERROR_CHECK(aes67_node_get_session(node, &session));
+    s_session = session;
+
+    /* Register SAP callback to auto-subscribe to discovered sources.
+     * This will add a sink for the first remote source found (e.g. SIENNA). */
+    aes67_sap_handle_t sap = NULL;
+    /* Get SAP handle from the node config - it's stored internally.
+     * For now, register via the session manager's SAP handle. */
+    if (aes67_node_get_sap(node, &sap) == ESP_OK && sap) {
+        aes67_sap_register_cb(sap, sap_discovery_cb, NULL);
+        ESP_LOGI(TAG, "SAP auto-subscribe enabled");
+
+        /* Check if any sources were already discovered before callback
+         * was registered (SAP starts during node_start). */
+        int remote_count = aes67_sap_get_remote_count(sap);
+        for (int i = 0; i < remote_count && !s_sink_added; i++) {
+            aes67_sap_remote_source_t remote;
+            if (aes67_sap_get_remote(sap, i, &remote) == ESP_OK) {
+                sap_discovery_cb(true, &remote, NULL);
+            }
+        }
+    }
+
+    /* Source stream is created in the SAP callback to match the
+     * discovered sink's codec and channel count. */
+
+    /* Start the WebUI (HTTP + WebSocket) on port 80 */
+    aes67_webui_handle_t webui = NULL;
+    if (aes67_webui_init(node, 80, &webui) == ESP_OK) {
+        aes67_webui_start(webui);
+    }
+
+    ESP_LOGI(TAG, "node ready");
+
+    /* Monitor loop: ISR health only */
+    extern void aes67_audio_log_isr_stats(void);
+    while (1) {
+        vTaskDelay(pdMS_TO_TICKS(2000));
+        aes67_audio_log_isr_stats();
+    }
+}

+ 99 - 0
sdkconfig.defaults

@@ -0,0 +1,99 @@
+CONFIG_IDF_TARGET="esp32p4"
+
+# L2 TAP for PTP
+CONFIG_ESP_NETIF_L2_TAP=y
+
+# PTP daemon
+CONFIG_NETUTILS_PTPD=y
+CONFIG_NETUTILS_PTPD_CLIENT=y
+CONFIG_NETUTILS_PTPD_SERVER=y
+CONFIG_NETUTILS_PTPD_SYNC_INTERVAL_MSEC=125
+CONFIG_NETUTILS_PTPD_ANNOUNCE_INTERVAL_MSEC=2000
+CONFIG_NETUTILS_PTPD_PRIORITY1=128
+CONFIG_NETUTILS_PTPD_PRIORITY2=128
+CONFIG_NETUTILS_PTPD_CLASS=248
+CONFIG_NETUTILS_PTPD_ACCURACY=254
+CONFIG_NETUTILS_PTPD_CLOCKSOURCE=160
+CONFIG_NETUTILS_PTPD_TWOSTEP_SYNC=y
+CONFIG_NETUTILS_PTPD_SEND_DELAYREQ=y
+CONFIG_NETUTILS_PTPD_DELAYREQ_AVGCOUNT=8
+CONFIG_NETUTILS_PTPD_SERVERPRIO=20
+CONFIG_NETUTILS_PTPD_STACKSIZE=6144
+CONFIG_NETUTILS_PTPD_MAX_PATH_DELAY_NS=1000000
+CONFIG_NETUTILS_PTPD_PATH_DELAY_STABILITY_NS=5000
+
+# PSRAM
+CONFIG_SPIRAM=y
+CONFIG_SPIRAM_SPEED_80M=y
+
+# lwIP - tuned for low-latency AES67 audio
+CONFIG_LWIP_SO_RCVBUF=y
+CONFIG_LWIP_NETBUF_RECVINFO=y
+CONFIG_LWIP_IP_DEFAULT_TTL=64
+
+# lwIP task: pin to core 0 (same as I2S/Ethernet for cache coherency)
+CONFIG_LWIP_TCPIP_TASK_PRIO=23
+CONFIG_LWIP_TCPIP_TASK_STACK_SIZE=4096
+CONFIG_LWIP_TCPIP_TASK_AFFINITY_CPU0=y
+
+# Larger mailboxes reduce queuing delay
+CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=128
+CONFIG_LWIP_UDP_RECVMBOX_SIZE=128
+
+# IRAM optimizations -- critical for reducing per-packet overhead
+CONFIG_LWIP_IRAM_OPTIMIZATION=y
+CONFIG_LWIP_EXTRA_IRAM_OPTIMIZATION=y
+
+# Disable features we don't need (reduces per-packet processing)
+CONFIG_LWIP_IP4_REASSEMBLY=n
+CONFIG_LWIP_STATS=n
+CONFIG_LWIP_ETHARP_TRUST_IP_MAC=y
+
+# DHCP: fast acquisition, restore last IP to skip discovery
+CONFIG_LWIP_DHCP_RESTORE_LAST_IP=y
+CONFIG_LWIP_DHCP_OPTIONS_LEN=68
+CONFIG_LWIP_DHCP_COARSE_TIMER_SECS=1
+
+# Ethernet DMA: 64 RX buffers for headroom
+CONFIG_ETH_ENABLED=y
+CONFIG_ETH_USE_ESP32_EMAC=y
+CONFIG_ETH_DMA_RX_BUFFER_NUM=64
+CONFIG_ETH_DMA_TX_BUFFER_NUM=16
+CONFIG_ETH_IRAM_OPTIMIZATION=y
+
+# mDNS
+CONFIG_MDNS_MAX_SERVICES=8
+
+# FreeRTOS
+CONFIG_FREERTOS_HZ=1000
+CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096
+CONFIG_FREERTOS_USE_TRACE_FACILITY=y
+
+# System
+CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
+CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
+CONFIG_ESP_TASK_WDT_TIMEOUT_S=10
+
+# Performance optimization (-O2, critical for real-time audio paths)
+CONFIG_COMPILER_OPTIMIZATION_PERF=y
+
+# I2S ISR runs from IRAM (safe even when flash cache is disabled)
+CONFIG_I2S_ISR_IRAM_SAFE=y
+
+# Disable power management (real-time audio needs consistent performance)
+CONFIG_PM_ENABLE=n
+
+# Disable WiFi (Ethernet only, saves resources and avoids interference)
+CONFIG_ESP_WIFI_ENABLED=n
+
+# Disable IPv6 (saves ~39KB flash, 2KB RAM, reduces packet processing)
+CONFIG_LWIP_IPV6=n
+
+# HTTP server with WebSocket support
+CONFIG_HTTPD_WS_SUPPORT=y
+CONFIG_LWIP_MAX_SOCKETS=16
+
+# Flash
+CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
+CONFIG_SPI_FLASH_SUPPORT_GD_CHIP=y
+CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y