Compare commits

..

3 Commits

Author SHA1 Message Date
bsncubed 044e9913a7 AES67 TX: send each packet right after its last sample is due
The periodic TX timer had an arbitrary phase against packet boundaries,
so packets waited up to one packet time (different after every restart).
- One-shot esp_timer aimed at the next packet's due time from PTP, with
  the clock re-read after sending.
- 1 kHz tone from a per-rate lookup table (one period = rate/1000
  samples) instead of sinf() per sample.
Measured (RTP time - arrival, 4 ms vs 1 ms packets, expected -3.07 ms
incl. wire time): -3.3 ms extra before, now -3.14 ms. Tone within
1.0 LSB of the ideal PTP-phased sine, no gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:48:47 +10:00
bsncubed bae6a8a2f9 Step 4.2: PTP-paced AES67 RTP sender with 1 kHz test tone
- aes67_tx: TX task woken by an esp_timer every packet time; sends every
  packet whose last sample is in the past (PTP time), RTP ts =
  (sample index from PTP + clk_offset) mod 2^32, packets aligned to
  multiples of the packet size. Sends only while PTP is locked; resyncs
  on lock, clock step or > 20 ms lag. L24/L16 big-endian, TTL/DSCP/
  multicast IF from config; a config save restarts TX.
- Built-in 1 kHz tone at -18 dBFS, phase from the PTP sample index;
  aes67_tx_set_source() pull callback for later sources (underruns
  counted, padded with silence). status: tx_packets, underruns.
- Verified with a receiver script on the PC: 1 ms L24 stereo 1000/s,
  0 gaps, ts step 48, tone -18.00 dBFS within 1.2 LSB of the ideal
  PTP-phased sine; also L16 mono 0.333 ms, 0.125 ms (8000/s) and 4 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:43:12 +10:00
bsncubed 3d05768f00 Step 4.1: /stream.sdp from the live config and PTP GM
- aes67_sdp_sap: aes67_sdp_build() builds the AES67 SDP (RFC 4566 +
  RFC 7273) with the same lines and order as the web UI preview;
  GET /stream.sdp serves it as application/sdp.
- aes67_ptp: public aes67_ptp_gm_id(), aes67_ptp_locked(),
  aes67_ptp_now_ns(). Clock IDs are now uppercase (RFC 7273 style), also
  in status.ptp, which the UI copies into ts-refclk.
- aes67_net: aes67_net_netif() returns the AES67 interface.
- Verified on board: /stream.sdp byte-identical to the UI's sdp() for
  the same config/status; mono, L16, ptime 0.333, ttl 8, clk_offset
  4294967295 and session_ver all follow the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:38:55 +10:00
14 changed files with 415 additions and 11 deletions
+5
View File
@@ -183,3 +183,8 @@ esp_err_t aes67_net_init(esp_eth_handle_t eth)
status_register(net_status);
return esp_eth_start(eth);
}
esp_netif_t *aes67_net_netif(void)
{
return s_netif;
}
+4
View File
@@ -4,6 +4,10 @@
#include "esp_err.h"
#include "esp_eth_driver.h"
#include "esp_netif.h"
// Attach a netif (DHCP client) to the Ethernet driver and start it. Logs link and IP events.
esp_err_t aes67_net_init(esp_eth_handle_t eth);
// The AES67 interface (untagged side once the VLAN split exists). NULL before aes67_net_init().
esp_netif_t *aes67_net_netif(void);
+20
View File
@@ -45,3 +45,23 @@ esp_err_t aes67_ptp_start(esp_eth_handle_t eth)
}
return err;
}
bool aes67_ptp_gm_id(char out[24])
{
return ptp_clock_gm_id(out);
}
bool aes67_ptp_locked(void)
{
return ptp_clock_locked();
}
esp_err_t aes67_ptp_now_ns(int64_t *ns)
{
eth_mac_time_t t;
esp_err_t err = ptp_hw_get_time(&t);
if (err == ESP_OK) {
*ns = (int64_t)t.seconds * 1000000000LL + t.nanoseconds;
}
return err;
}
+10
View File
@@ -2,6 +2,9 @@
// Core component: must not depend on main/ (project code).
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_eth_driver.h"
@@ -9,3 +12,10 @@
esp_err_t aes67_ptp_init(void);
// Start the EMAC PTP clock and the PTP task on this Ethernet interface.
esp_err_t aes67_ptp_start(esp_eth_handle_t eth);
// Current GM clockIdentity as "XX-XX-XX-XX-XX-XX-XX-XX" (RFC 7273 form). false if none selected.
bool aes67_ptp_gm_id(char out[24]);
// Servo locked to the GM.
bool aes67_ptp_locked(void);
// PTP time (EMAC hardware clock) in ns.
esp_err_t aes67_ptp_now_ns(int64_t *ns);
+17 -1
View File
@@ -150,7 +150,7 @@ static int64_t mac_ns(const eth_mac_time_t *t)
static void fmt_id(char *out, const uint8_t *id)
{
sprintf(out, "%02x-%02x-%02x-%02x-%02x-%02x-%02x-%02x", id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7]);
sprintf(out, "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X", id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7]);
}
// IEEE 1588 dataset comparison (without the topology part): <0 if a is better.
@@ -487,6 +487,22 @@ static void ptp_task(void *arg)
}
}
bool ptp_clock_gm_id(char out[24])
{
xSemaphoreTake(s.lock, portMAX_DELAY);
bool valid = s.gm.valid;
if (valid) {
fmt_id(out, s.gm.gm_id);
}
xSemaphoreGive(s.lock);
return valid;
}
bool ptp_clock_locked(void)
{
return s.locked;
}
void ptp_clock_status(cJSON *st)
{
cJSON *p = cJSON_AddObjectToObject(st, "ptp");
+2
View File
@@ -6,5 +6,7 @@
#include "esp_netif.h"
esp_err_t ptp_clock_start(esp_netif_t *netif);
bool ptp_clock_gm_id(char out[24]);
bool ptp_clock_locked(void);
// Adds the "ptp" object to /api/status.
void ptp_clock_status(cJSON *st);
+2 -1
View File
@@ -1,2 +1,3 @@
idf_component_register(SRCS "aes67_sdp_sap.c"
INCLUDE_DIRS "include")
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_net aes67_ptp aes67_web esp_netif)
+84 -2
View File
@@ -1,7 +1,89 @@
#include "aes67_sdp_sap.h"
// Stub (build step 0).
#include <stdio.h>
#include <string.h>
#include "aes67_cfg.h"
#include "aes67_net.h"
#include "aes67_ptp.h"
#include "aes67_web.h"
#include "esp_netif.h"
#define SDP_MAX 1024
// Same lines, in the same order, as the SDP preview in web/index.html.
size_t aes67_sdp_build(char *buf, size_t size)
{
cJSON *a = cfg_get("aes67");
cJSON *p = cfg_get("ptp");
if (!a || !p) {
cJSON_Delete(a);
cJSON_Delete(p);
return 0;
}
#define NUM(o, k) cJSON_GetObjectItemCaseSensitive(o, k)->valuedouble
#define STR(o, k) cJSON_GetObjectItemCaseSensitive(o, k)->valuestring
char ip[16] = "0.0.0.0";
esp_netif_ip_info_t info;
esp_netif_t *netif = aes67_net_netif();
if (netif && esp_netif_get_ip_info(netif, &info) == ESP_OK && info.ip.addr) {
snprintf(ip, sizeof(ip), IPSTR, IP2STR(&info.ip));
}
char gm[24];
if (!aes67_ptp_gm_id(gm)) {
strcpy(gm, "00-00-00-00-00-00-00-00");
}
int ch = (int)NUM(a, "channels");
char info_ch[16];
snprintf(info_ch, sizeof(info_ch), ch == 1 ? "mono" : ch == 2 ? "stereo" : "%d ch", ch);
int dom = (int)NUM(p, "domain");
int pt = (int)NUM(a, "pt");
int n = snprintf(buf, size,
"v=0\r\n"
"o=- %.0f %.0f IN IP4 %s\r\n"
"s=%s\r\n"
"c=IN IP4 %s/%d\r\n"
"t=0 0\r\n"
"a=clock-domain:PTPv2 %d\r\n"
"m=audio %d RTP/AVP %d\r\n"
"i=%s\r\n"
"a=rtpmap:%d %s/%d/%d\r\n"
"a=recvonly\r\n"
"a=ptime:%g\r\n"
"a=ts-refclk:ptp=IEEE1588-2008:%s:%d\r\n"
"a=mediaclk:direct=%.0f\r\n",
NUM(a, "session_id"), NUM(a, "session_ver"), ip,
STR(a, "name"),
STR(a, "mcast"), (int)NUM(a, "ttl"),
dom,
(int)NUM(a, "port"), pt,
info_ch,
pt, STR(a, "encoding"), (int)NUM(a, "rate"), ch,
NUM(a, "ptime"),
gm, dom,
NUM(a, "clk_offset"));
#undef NUM
#undef STR
cJSON_Delete(a);
cJSON_Delete(p);
return n > 0 && (size_t)n < size ? (size_t)n : 0;
}
static esp_err_t sdp_get(httpd_req_t *req)
{
char buf[SDP_MAX];
size_t len = aes67_sdp_build(buf, sizeof(buf));
if (!len) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SDP build failed");
}
httpd_resp_set_type(req, "application/sdp");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, buf, len);
}
esp_err_t aes67_sdp_sap_init(void)
{
return ESP_OK;
static const httpd_uri_t uri = { .uri = "/stream.sdp", .method = HTTP_GET, .handler = sdp_get };
return web_register_uri(&uri);
}
@@ -2,6 +2,11 @@
// Core component: must not depend on main/ (project code).
#pragma once
#include <stddef.h>
#include "esp_err.h"
// Registers GET /stream.sdp. (SAP: step 5.)
esp_err_t aes67_sdp_sap_init(void);
// Build the current SDP (RFC 4566 + RFC 7273, AES67). Returns the length, or 0 if it didn't fit.
size_t aes67_sdp_build(char *buf, size_t size);
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS "aes67_tx.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web lwip)
PRIV_REQUIRES aes67_net aes67_ptp aes67_web esp_netif esp_timer lwip)
+249 -4
View File
@@ -1,11 +1,35 @@
#include "aes67_tx.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "aes67_cfg.h"
#include "aes67_net.h"
#include "aes67_ptp.h"
#include "aes67_web.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "lwip/inet.h"
#include "lwip/sockets.h"
#define MAX_FRAMES 192 // 4 ms at 48 kHz
#define MAX_CH 2
#define RTP_HDR 12
#define MAX_LAG_NS 20000000 // more than 20 ms behind: resync instead of bursting
#define TONE_HZ 1000
#define TONE_DBFS -18.0
#define WAKE_MARGIN_US 20 // wake just after a packet's last sample is due
static const char *TAG = "aes67_tx";
static TaskHandle_t s_task;
static volatile aes67_tx_read_cb_t s_read;
static volatile bool s_reconfig = true;
static volatile uint32_t s_packets, s_underruns;
// Defaults follow the Riedel Director 4-wire AES67 output; channels 2 (core default).
static const char AES67_DEFAULTS[] =
"{\"name\":\"AES67\",\"enabled\":true,\"discovery\":\"sap\",\"mcast\":\"239.69.1.10\","
@@ -58,16 +82,237 @@ static bool aes67_validate(const cJSON *g, char *err, size_t n)
return ok;
}
// Stub (build step 2): counters stay 0 until the RTP sender exists (step 4).
static void tx_status(cJSON *st)
{
cJSON_AddNumberToObject(st, "tx_packets", 0);
cJSON_AddNumberToObject(st, "underruns", 0);
cJSON_AddNumberToObject(st, "tx_packets", s_packets);
cJSON_AddNumberToObject(st, "underruns", s_underruns);
}
static void aes67_apply(const cJSON *g)
{
s_reconfig = true; // the TX task picks up the new settings
}
void aes67_tx_set_source(aes67_tx_read_cb_t read)
{
s_read = read;
}
/* ----- sender ----- */
typedef struct {
bool enabled;
struct sockaddr_in dst;
uint8_t ttl, dscp, pt;
uint32_t ssrc, clk_offset;
int channels, rate, bytes; // bytes per sample: 3 (L24) or 2 (L16)
int frames; // samples per channel per packet
} tx_cfg_t;
static bool load(tx_cfg_t *c)
{
cJSON *a = cfg_get("aes67");
if (!a) {
return false;
}
#define NUM(k) cJSON_GetObjectItemCaseSensitive(a, k)->valuedouble
c->enabled = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(a, "enabled"));
memset(&c->dst, 0, sizeof(c->dst));
c->dst.sin_family = AF_INET;
c->dst.sin_port = htons((uint16_t)NUM("port"));
inet_aton(cJSON_GetObjectItemCaseSensitive(a, "mcast")->valuestring, &c->dst.sin_addr);
c->ttl = (uint8_t)NUM("ttl");
c->dscp = (uint8_t)NUM("dscp");
c->pt = (uint8_t)NUM("pt");
c->ssrc = (uint32_t)NUM("ssrc");
c->clk_offset = (uint32_t)NUM("clk_offset");
c->channels = (int)NUM("channels");
c->rate = (int)NUM("rate");
c->bytes = strcmp(cJSON_GetObjectItemCaseSensitive(a, "encoding")->valuestring, "L16") == 0 ? 2 : 3;
c->frames = (int)lround(NUM("ptime") * c->rate / 1000.0); // 0.333 ms -> 16 at 48 kHz
#undef NUM
cJSON_Delete(a);
return c->frames > 0 && c->frames <= MAX_FRAMES && c->channels <= MAX_CH;
}
static int open_socket(const tx_cfg_t *c)
{
int fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (fd < 0) {
return -1;
}
int tos = c->dscp << 2;
uint8_t ttl = c->ttl, loop = 0;
setsockopt(fd, IPPROTO_IP, IP_TOS, &tos, sizeof(tos));
setsockopt(fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
setsockopt(fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop));
esp_netif_ip_info_t ip;
if (esp_netif_get_ip_info(aes67_net_netif(), &ip) == ESP_OK) {
struct in_addr ifa = { .s_addr = ip.ip.addr };
setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &ifa, sizeof(ifa));
}
return fd;
}
// Sample index since the PTP epoch (no overflow: seconds * rate first).
static int64_t ns_to_samples(int64_t ns, int rate)
{
return (ns / 1000000000LL) * rate + (ns % 1000000000LL) * rate / 1000000000LL;
}
// PTP time at which sample index s starts (rounded up).
static int64_t samples_to_ns(int64_t s, int rate)
{
return (s / rate) * 1000000000LL + ((s % rate) * 1000000000LL + rate - 1) / rate;
}
// 1 kHz tone, phase from the PTP sample index, so every sender's tone lines up.
// One period is rate / 1000 samples (48 at 48 kHz, 96 at 96 kHz): precomputed table.
static void tone(int32_t *buf, int frames, int channels, int rate, int64_t s0)
{
static int32_t table[96000 / TONE_HZ];
static int table_rate;
int period = rate / TONE_HZ;
if (table_rate != rate) {
double amp = pow(10.0, TONE_DBFS / 20.0) * 2147483647.0;
for (int i = 0; i < period; i++) {
table[i] = (int32_t)(amp * sin(2.0 * M_PI * i / period));
}
table_rate = rate;
}
int ph = (int)(s0 % period);
for (int i = 0; i < frames; i++) {
int32_t v = table[ph];
ph = ph + 1 == period ? 0 : ph + 1;
for (int ch = 0; ch < channels; ch++) {
buf[i * channels + ch] = v;
}
}
}
static void timer_cb(void *arg)
{
xTaskNotifyGive(s_task);
}
static void tx_task(void *arg)
{
tx_cfg_t c = { 0 };
int fd = -1;
esp_timer_handle_t timer = NULL;
const esp_timer_create_args_t targs = { .callback = timer_cb, .name = "aes67_tx" };
esp_timer_create(&targs, &timer);
static int32_t pcm[MAX_FRAMES * MAX_CH];
static uint8_t pkt[RTP_HDR + MAX_FRAMES * MAX_CH * 3];
uint16_t seq = (uint16_t)esp_random();
int64_t next = 0; // sample index of the next packet; 0 = resync needed
const char *state = NULL, *new_state;
while (1) {
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
if (s_reconfig) {
s_reconfig = false;
if (fd >= 0) {
close(fd);
fd = -1;
}
esp_timer_stop(timer);
if (!load(&c)) {
ESP_LOGE(TAG, "unsupported config (ptime/rate/channels)");
c.enabled = false;
}
next = 0;
if (c.enabled) {
fd = open_socket(&c);
}
}
int64_t now;
if (!c.enabled || fd < 0) {
new_state = "disabled";
} else if (!aes67_ptp_locked() || aes67_ptp_now_ns(&now) != ESP_OK) {
new_state = "waiting for PTP lock";
next = 0;
} else {
new_state = "sending";
int64_t due = ns_to_samples(now, c.rate);
// (Re)start aligned to a packet boundary, or after falling behind / a clock step back.
if (!next || due - next > (int64_t)c.rate * MAX_LAG_NS / 1000000000LL || next - due > 2 * c.frames) {
if (next) {
ESP_LOGW(TAG, "resync (%lld samples off)", due - next);
}
next = (due / c.frames + 1) * c.frames;
}
// Send every packet whose last sample is in the past.
while (samples_to_ns(next + c.frames, c.rate) <= now) {
aes67_tx_read_cb_t read = s_read;
if (read) {
size_t got = read(pcm, c.frames);
if (got < (size_t)c.frames) {
memset(pcm + got * c.channels, 0, (c.frames - got) * c.channels * sizeof(int32_t));
s_underruns++;
}
} else {
tone(pcm, c.frames, c.channels, c.rate, next);
}
uint32_t ts = (uint32_t)next + c.clk_offset;
pkt[0] = 0x80; // V=2
pkt[1] = c.pt & 0x7f;
pkt[2] = seq >> 8;
pkt[3] = seq & 0xff;
pkt[4] = ts >> 24;
pkt[5] = ts >> 16;
pkt[6] = ts >> 8;
pkt[7] = ts;
pkt[8] = c.ssrc >> 24;
pkt[9] = c.ssrc >> 16;
pkt[10] = c.ssrc >> 8;
pkt[11] = c.ssrc;
uint8_t *p = pkt + RTP_HDR;
for (int i = 0; i < c.frames * c.channels; i++) {
uint32_t v = (uint32_t)pcm[i]; // big-endian, top bytes of the 32-bit sample
*p++ = v >> 24;
*p++ = v >> 16;
if (c.bytes == 3) {
*p++ = v >> 8;
}
}
if (sendto(fd, pkt, p - pkt, 0, (struct sockaddr *)&c.dst, sizeof(c.dst)) > 0) {
s_packets++;
}
seq++;
next += c.frames;
}
// Wake when the next packet's last sample is due (PTP time), not on a free-running
// period, so each packet leaves right after it is complete. Re-read the clock:
// building and sending took time.
aes67_ptp_now_ns(&now);
int64_t wait_us = (samples_to_ns(next + c.frames, c.rate) - now) / 1000 + WAKE_MARGIN_US;
esp_timer_stop(timer);
esp_timer_start_once(timer, wait_us < 50 ? 50 : wait_us > 10000 ? 10000 : wait_us);
}
if (new_state != state) {
state = new_state;
if (c.enabled && state[0] == 's') {
ESP_LOGI(TAG, "%s: %s:%u, %s/%d/%d, %d samples per packet, %s", state,
inet_ntoa(c.dst.sin_addr), ntohs(c.dst.sin_port), c.bytes == 3 ? "L24" : "L16",
c.rate, c.channels, c.frames, s_read ? "source" : "1 kHz test tone");
} else {
ESP_LOGI(TAG, "%s", state);
}
}
}
}
esp_err_t aes67_tx_start(void)
{
return xTaskCreate(tx_task, "aes67_tx", 4096, NULL, 16, &s_task) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
}
esp_err_t aes67_tx_init(void)
{
esp_err_t err = cfg_register("aes67", AES67_DEFAULTS, aes67_validate, NULL);
esp_err_t err = cfg_register("aes67", AES67_DEFAULTS, aes67_validate, aes67_apply);
if (err != ESP_OK) {
return err;
}
+12 -1
View File
@@ -2,7 +2,18 @@
// Core component: must not depend on main/ (project code).
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
// Registers the "aes67" config group and the TX status fields. (Sender itself: step 4.)
// Audio source: fill buf with frames x channels interleaved samples (full scale = INT32_MAX).
// Return the number of frames delivered; fewer than asked counts as an underrun (padded with silence).
typedef size_t (*aes67_tx_read_cb_t)(int32_t *buf, size_t frames);
// Registers the "aes67" config group and the TX status fields.
esp_err_t aes67_tx_init(void);
// Start the sender task. Sends while PTP is locked and aes67.enabled is set.
esp_err_t aes67_tx_start(void);
// Set the audio source. NULL = built-in 1 kHz test tone at -18 dBFS, phase-locked to PTP time.
void aes67_tx_set_source(aes67_tx_read_cb_t read);
+1 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(SRCS "main.c" "project_cfg.c"
INCLUDE_DIRS "."
REQUIRES esp_app_format esp_hw_support
aes67_board aes67_health aes67_net aes67_ota aes67_ptp
aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap
aes67_syslog aes67_tx aes67_web)
+3
View File
@@ -3,6 +3,7 @@
#include "aes67_net.h"
#include "aes67_ota.h"
#include "aes67_ptp.h"
#include "aes67_sdp_sap.h"
#include "aes67_syslog.h"
#include "aes67_tx.h"
#include "aes67_web.h"
@@ -36,5 +37,7 @@ void app_main(void)
ESP_ERROR_CHECK(aes67_syslog_init());
project_cfg_register();
ESP_ERROR_CHECK(aes67_ota_init());
ESP_ERROR_CHECK(aes67_sdp_sap_init());
ESP_ERROR_CHECK(aes67_tx_start());
ESP_ERROR_CHECK(aes67_web_start());
}