From d3897f31799713988078f92d214ec8f6ccc61324 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 14:36:15 +1000 Subject: [PATCH] Step 7.1: player plumbing (PSRAM ring -> AES67 TX), test tone mode - main/audio_ring: SPSC ring of interleaved int32 frames in PSRAM (4 s at 48 kHz stereo), lock-free with acquire/release counters; the TX pull callback never blocks. - main/player: source selection from source.mode and the pull callback for aes67_tx. tone -> the core's PTP-phased 1 kHz tone; off -> silence; hls/spotify -> ring with 1 s prefill (silence while buffering is not an underrun; running dry is, and prefills again). Status fields active_source, source_state, spotify_state, buffer_ms. source config applies live. - New source.mode "tone" (validation, UI dropdown, doc) for commissioning. - Temporary 440 Hz producer in hls mode (until the HLS player exists). - Verified: tone 999.7 Hz -18 dBFS; off silent; hls 440.0 Hz with max sample step 60816 (ideal sine 60825, i.e. no discontinuities), buffer 3983 ms, 0 underruns; spotify silent/not implemented. Co-Authored-By: Claude Opus 5.5 --- docs/hardware-and-design-notes.md | 3 +- main/CMakeLists.txt | 4 +- main/audio_ring.c | 76 ++++++++++++++++++++ main/audio_ring.h | 15 ++++ main/main.c | 2 + main/player.c | 116 ++++++++++++++++++++++++++++++ main/player.h | 9 +++ main/project_cfg.c | 5 +- web/index.html | 1 + 9 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 main/audio_ring.c create mode 100644 main/audio_ring.h create mode 100644 main/player.c create mode 100644 main/player.h diff --git a/docs/hardware-and-design-notes.md b/docs/hardware-and-design-notes.md index 8b2fb45..c14fdd0 100644 --- a/docs/hardware-and-design-notes.md +++ b/docs/hardware-and-design-notes.md @@ -30,8 +30,9 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44 - Firmware needs Spotify events: cspot connect, disconnect, play, pause. ## Project config group -- source: {mode: spotify|hls|auto|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause} +- source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause} - Project status fields: active_source, source_state, spotify_state, buffer_ms +- mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence. ## Player control API (project routes) - GET /api/player -> {source, forced, state(playing|paused|stopped|buffering|idle), artist, title, album, position_ms, duration_ms, volume(0-100), can:{pause,next,prev,seek}} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 501eb91..f62988f 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ -idf_component_register(SRCS "main.c" "project_cfg.c" +idf_component_register(SRCS "main.c" "project_cfg.c" "player.c" "audio_ring.c" INCLUDE_DIRS "." - REQUIRES esp_app_format esp_hw_support + REQUIRES esp_app_format esp_hw_support heap aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap aes67_syslog aes67_tx aes67_web) diff --git a/main/audio_ring.c b/main/audio_ring.c new file mode 100644 index 0000000..58fbe2b --- /dev/null +++ b/main/audio_ring.c @@ -0,0 +1,76 @@ +#include "audio_ring.h" + +#include + +#include "esp_heap_caps.h" + +static int32_t *s_buf; +static size_t s_cap; // frames +static int s_ch; +// Monotonic frame counters; level = wr - rd. Written by one side each (acquire/release). +static uint32_t s_wr, s_rd; + +esp_err_t audio_ring_init(size_t capacity_frames, int channels) +{ + s_buf = heap_caps_malloc(capacity_frames * channels * sizeof(int32_t), MALLOC_CAP_SPIRAM); + if (!s_buf) { + return ESP_ERR_NO_MEM; + } + s_cap = capacity_frames; + s_ch = channels; + return ESP_OK; +} + +size_t audio_ring_level(void) +{ + return __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE) - __atomic_load_n(&s_rd, __ATOMIC_ACQUIRE); +} + +size_t audio_ring_space(void) +{ + return s_cap - audio_ring_level(); +} + +// Copy n frames between the ring (starting at frame index pos) and a linear buffer. +static void copy(int32_t *ring_to_lin, const int32_t *lin_to_ring, uint32_t pos, size_t n) +{ + size_t start = pos % s_cap; + size_t first = n < s_cap - start ? n : s_cap - start; + size_t fb = first * s_ch * sizeof(int32_t), rb = (n - first) * s_ch * sizeof(int32_t); + if (ring_to_lin) { + memcpy(ring_to_lin, s_buf + start * s_ch, fb); + memcpy(ring_to_lin + first * s_ch, s_buf, rb); + } else { + memcpy(s_buf + start * s_ch, lin_to_ring, fb); + memcpy(s_buf, lin_to_ring + first * s_ch, rb); + } +} + +size_t audio_ring_write(const int32_t *frames, size_t n) +{ + uint32_t wr = __atomic_load_n(&s_wr, __ATOMIC_RELAXED); + size_t space = s_cap - (wr - __atomic_load_n(&s_rd, __ATOMIC_ACQUIRE)); + n = n < space ? n : space; + if (n) { + copy(NULL, frames, wr, n); + __atomic_store_n(&s_wr, wr + n, __ATOMIC_RELEASE); + } + return n; +} + +size_t audio_ring_read(int32_t *frames, size_t n) +{ + uint32_t rd = __atomic_load_n(&s_rd, __ATOMIC_RELAXED); + size_t level = __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE) - rd; + n = n < level ? n : level; + if (n) { + copy(frames, NULL, rd, n); + __atomic_store_n(&s_rd, rd + n, __ATOMIC_RELEASE); + } + return n; +} + +void audio_ring_flush(void) +{ + __atomic_store_n(&s_rd, __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE), __ATOMIC_RELEASE); +} diff --git a/main/audio_ring.h b/main/audio_ring.h new file mode 100644 index 0000000..cc99cd3 --- /dev/null +++ b/main/audio_ring.h @@ -0,0 +1,15 @@ +// Single-producer / single-consumer ring of interleaved int32 frames in PSRAM. +// The producer is a source task, the consumer the AES67 TX pull callback (must never block). +#pragma once + +#include +#include + +#include "esp_err.h" + +esp_err_t audio_ring_init(size_t capacity_frames, int channels); +size_t audio_ring_level(void); // frames available to read +size_t audio_ring_space(void); // frames that can be written +size_t audio_ring_write(const int32_t *frames, size_t n); // producer; returns frames written +size_t audio_ring_read(int32_t *frames, size_t n); // consumer; returns frames read +void audio_ring_flush(void); // consumer side: drop everything buffered diff --git a/main/main.c b/main/main.c index d54712f..918f22c 100644 --- a/main/main.c +++ b/main/main.c @@ -10,6 +10,7 @@ #include "esp_app_desc.h" #include "esp_chip_info.h" #include "esp_log.h" +#include "player.h" #include "project_cfg.h" static const char *TAG = "main"; @@ -39,6 +40,7 @@ void app_main(void) project_cfg_register(); ESP_ERROR_CHECK(aes67_ota_init()); ESP_ERROR_CHECK(aes67_sdp_sap_init()); + ESP_ERROR_CHECK(player_init()); ESP_ERROR_CHECK(aes67_tx_start()); ESP_ERROR_CHECK(aes67_web_start()); } diff --git a/main/player.c b/main/player.c new file mode 100644 index 0000000..4c4fc8d --- /dev/null +++ b/main/player.c @@ -0,0 +1,116 @@ +#include "player.h" + +#include +#include + +#include "aes67_cfg.h" +#include "aes67_tx.h" +#include "aes67_web.h" +#include "audio_ring.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define RATE 48000 +#define CHANNELS 2 +#define RING_FRAMES (4 * RATE) // 4 s in PSRAM +#define PREFILL_FRAMES (RATE) // 1 s before (re)starting output + +static const char *TAG = "player"; + +typedef enum { SRC_TONE, SRC_OFF, SRC_HLS, SRC_SPOTIFY } src_t; +static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; + +static volatile src_t s_src = SRC_OFF; +static volatile bool s_playing; // ring output running (after prefill) +static volatile bool s_flush; // consumer drops buffered audio on the next read + +static src_t mode_of(const char *m) +{ + return !strcmp(m, "tone") ? SRC_TONE : !strcmp(m, "hls") ? SRC_HLS : + !strcmp(m, "spotify") ? SRC_SPOTIFY : !strcmp(m, "auto") ? SRC_HLS /* failover: step 7 */ : SRC_OFF; +} + +// AES67 TX pull callback (TX task, must not block). Silence while buffering or off: not an underrun. +static size_t player_read(int32_t *buf, size_t frames) +{ + if (s_flush) { + audio_ring_flush(); + s_flush = false; + s_playing = false; + } + if (s_src == SRC_OFF || s_src == SRC_SPOTIFY) { // Spotify: step 7 (cspot) + memset(buf, 0, frames * CHANNELS * sizeof(int32_t)); + return frames; + } + if (!s_playing) { + if (audio_ring_level() < PREFILL_FRAMES) { + memset(buf, 0, frames * CHANNELS * sizeof(int32_t)); + return frames; + } + s_playing = true; + } + size_t got = audio_ring_read(buf, frames); + if (got < frames) { + s_playing = false; // ran dry: underrun (counted by TX), prefill again + } + return got; +} + +void player_apply(const cJSON *source) +{ + src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring); + if (src == s_src) { + return; + } + s_src = src; + s_flush = true; + // The test tone is TX's own (phase-locked to PTP); everything else goes through player_read. + aes67_tx_set_source(src == SRC_TONE ? NULL : player_read); + ESP_LOGI(TAG, "source: %s", SRC_NAME[src]); +} + +/* ----- Step 7.1: temporary test producer (440 Hz into the ring), replaced by the HLS player ----- */ + +static void test_producer(void *arg) +{ + static int32_t chunk[480 * CHANNELS]; + uint32_t phase = 0; + const double amp = pow(10.0, -18.0 / 20.0) * 2147483647.0; + while (1) { + if (s_src != SRC_HLS || audio_ring_space() < 480) { + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + for (int i = 0; i < 480; i++, phase++) { + int32_t v = (int32_t)(amp * sin(2.0 * M_PI * 440.0 * (phase % RATE) / RATE)); + chunk[i * 2] = chunk[i * 2 + 1] = v; + } + audio_ring_write(chunk, 480); + } +} + +static void player_status(cJSON *st) +{ + const char *state = s_src == SRC_TONE ? "playing" : s_src == SRC_OFF ? "idle" : + s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering"; + cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]); + cJSON_AddStringToObject(st, "source_state", state); + cJSON_AddStringToObject(st, "spotify_state", "not implemented"); + cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); +} + +esp_err_t player_init(void) +{ + esp_err_t err = audio_ring_init(RING_FRAMES, CHANNELS); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ring buffer: %s", esp_err_to_name(err)); + return err; + } + cJSON *src = cfg_get("source"); + s_src = (src_t)-1; + player_apply(src); + cJSON_Delete(src); + xTaskCreate(test_producer, "test_prod", 3072, NULL, 5, NULL); + return status_register(player_status); +} diff --git a/main/player.h b/main/player.h new file mode 100644 index 0000000..271aa58 --- /dev/null +++ b/main/player.h @@ -0,0 +1,9 @@ +// Project player: picks the audio source for AES67 TX (test tone, silence, HLS, Spotify). +#pragma once + +#include "cJSON.h" +#include "esp_err.h" + +esp_err_t player_init(void); +// Apply the "source" config group (mode etc.). +void player_apply(const cJSON *source); diff --git a/main/project_cfg.c b/main/project_cfg.c index 8cc561d..b7ab14e 100644 --- a/main/project_cfg.c +++ b/main/project_cfg.c @@ -3,6 +3,7 @@ #include "aes67_cfg.h" #include "esp_err.h" +#include "player.h" static const char SOURCE_DEFAULTS[] = "{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\"," @@ -10,7 +11,7 @@ static const char SOURCE_DEFAULTS[] = static bool source_validate(const cJSON *g, char *err, size_t n) { - static const char *const modes[] = { "spotify", "hls", "auto", "off", NULL }; + static const char *const modes[] = { "spotify", "hls", "auto", "tone", "off", NULL }; static const double bitrates[] = { 96, 160, 320 }; return cfg_check_enum(g, "mode", modes, err, n) && cfg_check_str(g, "spotify_name", 1, 63, err, n) && @@ -29,5 +30,5 @@ void project_cfg_defaults(void) void project_cfg_register(void) { - ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, NULL)); + ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply)); } diff --git a/web/index.html b/web/index.html index 050a662..c5dd92a 100644 --- a/web/index.html +++ b/web/index.html @@ -64,6 +64,7 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next +