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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:36:15 +10:00
parent 2a7ab7922d
commit d3897f3179
9 changed files with 226 additions and 5 deletions
+2 -1
View File
@@ -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}}
+2 -2
View File
@@ -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)
+76
View File
@@ -0,0 +1,76 @@
#include "audio_ring.h"
#include <string.h>
#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);
}
+15
View File
@@ -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 <stddef.h>
#include <stdint.h>
#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
+2
View File
@@ -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());
}
+116
View File
@@ -0,0 +1,116 @@
#include "player.h"
#include <math.h>
#include <string.h>
#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);
}
+9
View File
@@ -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);
+3 -2
View File
@@ -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));
}
+1
View File
@@ -64,6 +64,7 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<option value="spotify">Spotify Connect</option>
<option value="hls">HLS / M3U8 URL</option>
<option value="auto">Spotify, fail over to HLS</option>
<option value="tone">Test tone (1 kHz, -18 dBFS)</option>
<option value="off">Off (silence)</option></select></label>
<label><span>Failover delay (s)</span><input name="source.failover_delay_s" type="number" min="0" max="3600"></label>
<label><span>Also fail over when paused</span><input name="source.failover_on_pause" type="checkbox"></label>