From c9e06da47c5cf535232b9b16c223283082fc2246 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 23:26:48 +1000 Subject: [PATCH] aes67_tx: mono sum; the source always delivers stereo The pull callback's contract said "stream channels", but the player always delivers stereo, so a 1-channel stream went out as L,R,L,R at half speed. Sources now always deliver AES67_TX_SRC_CHANNELS (2); TX maps to the stream: 2 ch as is, 1 ch = (L+R)/2 in 64 bit with mono_sum (can't clip), else L. Checked: mono packets 156 B, 1000/s, 48 samples each, SDP L24/48000/1. Co-Authored-By: Claude Opus 5.5 --- components/aes67_tx/aes67_tx.c | 19 +++++++++++++++++-- components/aes67_tx/include/aes67_tx.h | 4 +++- docs/aes67-core-base.md | 2 +- main/player.c | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/components/aes67_tx/aes67_tx.c b/components/aes67_tx/aes67_tx.c index 4ff509f..46dd122 100644 --- a/components/aes67_tx/aes67_tx.c +++ b/components/aes67_tx/aes67_tx.c @@ -106,6 +106,7 @@ typedef struct { uint8_t ttl, dscp, pt; uint32_t ssrc, clk_offset; int channels, rate, bytes; // bytes per sample: 3 (L24) or 2 (L16) + bool mono_sum; // 1-channel stream carries (L + R) / 2, else L int frames; // samples per channel per packet } tx_cfg_t; @@ -127,6 +128,7 @@ static bool load(tx_cfg_t *c) c->ssrc = (uint32_t)NUM("ssrc"); c->clk_offset = (uint32_t)NUM("clk_offset"); c->channels = (int)NUM("channels"); + c->mono_sum = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(a, "mono_sum")); 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 @@ -168,6 +170,15 @@ static int64_t samples_to_ns(int64_t s, int 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. +// Stereo source -> 1-channel stream, in place. The sum is in 64 bit, so it can't clip; identical +// L/R comes out at the same level. +static void to_mono(int32_t *buf, int frames, bool sum) +{ + for (int i = 0; i < frames; i++) { + buf[i] = sum ? (int32_t)(((int64_t)buf[2 * i] + buf[2 * i + 1]) / 2) : buf[2 * i]; + } +} + static void tone(int32_t *buf, int frames, int channels, int rate, int64_t s0) { static int32_t table[96000 / TONE_HZ]; @@ -202,7 +213,7 @@ static void tx_task(void *arg) 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 int32_t pcm[MAX_FRAMES * (MAX_CH > AES67_TX_SRC_CHANNELS ? MAX_CH : AES67_TX_SRC_CHANNELS)]; 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 @@ -250,9 +261,13 @@ static void tx_task(void *arg) 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)); + memset(pcm + got * AES67_TX_SRC_CHANNELS, 0, + (c.frames - got) * AES67_TX_SRC_CHANNELS * sizeof(int32_t)); s_underruns++; } + if (c.channels == 1) { + to_mono(pcm, c.frames, c.mono_sum); + } } else { tone(pcm, c.frames, c.channels, c.rate, next); } diff --git a/components/aes67_tx/include/aes67_tx.h b/components/aes67_tx/include/aes67_tx.h index d6bc5e3..39d5559 100644 --- a/components/aes67_tx/include/aes67_tx.h +++ b/components/aes67_tx/include/aes67_tx.h @@ -7,8 +7,10 @@ #include "esp_err.h" -// Audio source: fill buf with frames x channels interleaved samples (full scale = INT32_MAX). +// Audio source: fill buf with frames x AES67_TX_SRC_CHANNELS interleaved samples (stereo, full scale = +// INT32_MAX), whatever the stream's channel count: TX maps it (mono stream: L, or (L+R)/2 with mono_sum). // Return the number of frames delivered; fewer than asked counts as an underrun (padded with silence). +#define AES67_TX_SRC_CHANNELS 2 typedef size_t (*aes67_tx_read_cb_t)(int32_t *buf, size_t frames); // Registers the "aes67" config group and the TX status fields. diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index 8ca17cc..684e884 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -142,7 +142,7 @@ components/ aes67_board/ board pin profiles (waveshare_esp32p4_eth.h, …), eth init aes67_net/ netif setup, optional VLAN split, DSCP/PCP helpers aes67_ptp/ PTPv2 ordinary clock: BMCA, servo, master, multicast/hybrid, EMAC HW timestamps, stats - aes67_tx/ RTP sender task, PTP-paced, pull callback: size_t (*read)(int32_t *buf, size_t frames); optional mono sum + aes67_tx/ RTP sender task, PTP-paced, pull callback: size_t (*read)(int32_t *buf, size_t frames), always stereo (AES67_TX_SRC_CHANNELS); TX maps it to the stream (mono: L, or (L+R)/2 with mono_sum) aes67_sdp_sap/ SDP builder/parser + SAP announcer aes67_syslog/ remote syslog aes67_health/ uptime, heap/PSRAM, temperature sensors (temps[] with min/max/warn) diff --git a/main/player.c b/main/player.c index 9714bd5..459ae7d 100644 --- a/main/player.c +++ b/main/player.c @@ -18,7 +18,7 @@ #include "spotify.h" #define RATE 48000 -#define CHANNELS 2 +#define CHANNELS AES67_TX_SRC_CHANNELS // the ring holds what TX pulls: stereo #define RING_FRAMES (4 * RATE) // 4 s in PSRAM #define PREFILL_FRAMES (RATE) // 1 s before (re)starting output #define FADE_FRAMES (RATE * 30 / 1000) // 30 ms ramp at a source switch