Files
lisael-box/main/audio/radio_pipeline.c
T
Clément Saillant e35058f53d chore(box): commit firmware base + feature docs
Backfills the working firmware that had stayed uncommitted across the
build sessions, so the branch is self-contained.

- audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player
- net: podcast (RSS), udp_log
- modes: balloons, plus calm/games/radio tweaks
- ui: fonts header, icons; build files (CMakeLists, idf_component.yml,
  sdkconfig.defaults)
- docs/superpowers: Histoires design + plan

Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls
settings); a fresh checkout must reconfigure those.
2026-06-15 00:20:47 +02:00

365 lines
15 KiB
C

// Webradio live-streaming pipeline for Lisael Box — NO ESP-ADF.
//
// Plays a live HTTP(S) MP3 webradio (Shoutcast/Icecast) straight onto the
// shared ES8311 codec, reusing the exact streaming-decode pattern of
// aac_player.c (esp_audio_simple_dec -> esp_codec_dev_write) but fed from the
// network instead of a FILE.
//
// Data flow (two FreeRTOS tasks + one StreamBuffer, no busy-spin anywhere):
//
// [HTTP task] esp_http_client GET url
// -> HTTP_EVENT_ON_DATA callback
// -> xStreamBufferSend(net_buf) (blocks if buffer full)
//
// [decode task] xStreamBufferReceive(net_buf) (blocks if buffer empty)
// -> esp_audio_simple_dec_process (MP3 -> PCM)
// -> esp_codec_dev_write() (blocks / paces playback)
//
// The codec is the rate limiter: esp_codec_dev_write() blocks until the I2S DMA
// drains, which naturally throttles the whole chain. If the network is slower
// than playback, the StreamBuffer empties and the decode task simply blocks on
// receive (no spinning). If the network is faster, the HTTP callback blocks on
// send until the decoder has consumed room (back-pressure). A single volatile
// s_stop flag unwinds both tasks cleanly; lisael_radio_stop() waits (bounded)
// for them to exit and restores the IDLE source.
#include "audio/radio_pipeline.h"
#include "audio/audio_player.h"
#include <string.h>
#include <stdlib.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "freertos/stream_buffer.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_codec_dev.h"
#include "esp_audio_simple_dec.h"
#include "esp_audio_simple_dec_default.h"
#include "esp_audio_dec_default.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h" // HTTPS CA bundle (RadioKing streams are https)
#include "net/udp_log.h" // temporary network debug
static const char *TAG = "radio";
// --- Tuning ------------------------------------------------------------------
#define NET_STREAM_BUF_BYTES (32 * 1024) // ~32 KB of buffered compressed MP3
#define NET_CHUNK_TIMEOUT_MS 2000 // max wait pushing a network chunk
#define DEC_READ_TIMEOUT_MS 2000 // max wait pulling MP3 from buffer
#define DEC_READ_CHUNK 4096 // bytes pulled per decode iteration
#define DEC_OUT_CAP_INIT 8192 // initial PCM output buffer
#define HTTP_RX_BUFFER 2048 // esp_http_client receive buffer
// --- State (single radio instance; sources are mutually exclusive) ----------
static volatile bool s_stop; // request both tasks to unwind
static volatile bool s_http_running; // HTTP task alive
static volatile bool s_dec_running; // decode task alive
static volatile bool s_playing; // public "is playing" state
static StreamBufferHandle_t s_net_buf; // compressed MP3 bytes: HTTP -> decoder
static TaskHandle_t s_http_task;
static TaskHandle_t s_dec_task;
static char s_url[512];
// Register the MP3 frame decoder + simple-decoder container parsers once.
static void register_decoders_once(void)
{
static bool done;
if (!done) {
esp_audio_dec_register_default(); // MP3/AAC frame decoders
esp_audio_simple_dec_register_default(); // simple-dec wrappers
done = true;
}
}
// ----------------------------------------------------------------------------
// HTTP task: pull bytes off the network and push them into the StreamBuffer.
// ----------------------------------------------------------------------------
// Called by esp_http_client from inside esp_http_client_perform() for every
// received body chunk. We forward the bytes into the StreamBuffer; the send
// blocks (bounded) when the buffer is full, applying back-pressure to the TCP
// read without ever busy-spinning. Returning ESP_FAIL aborts the transfer,
// which is how a stop request unwinds the (otherwise endless) live stream.
static esp_err_t http_event(esp_http_client_event_t *evt)
{
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) {
static bool first = false;
if (!first) { first = true; lisael_udp_send("RADIO: first net data, len=%d", evt->data_len); }
const uint8_t *p = (const uint8_t *)evt->data;
size_t remaining = (size_t)evt->data_len;
while (remaining > 0) {
if (s_stop) {
return ESP_FAIL; // abort perform() -> HTTP task returns
}
size_t sent = xStreamBufferSend(s_net_buf, p, remaining,
pdMS_TO_TICKS(NET_CHUNK_TIMEOUT_MS));
p += sent;
remaining -= sent;
// If sent < remaining we timed out with the buffer full (decoder
// stalled / paused) — loop again, re-checking s_stop. No spin: the
// send call itself blocks for up to NET_CHUNK_TIMEOUT_MS.
}
}
return ESP_OK;
}
static void http_task(void *arg)
{
(void)arg;
s_http_running = true;
ESP_LOGI(TAG, "http: GET %s", s_url);
esp_http_client_config_t cfg = {
.url = s_url,
.event_handler = http_event,
.timeout_ms = 15000,
.buffer_size = HTTP_RX_BUFFER,
// Follow 3xx automatically: RadioKing /play/<slug> endpoints redirect to
// the live listen.radioking.com mount, and the stream itself is one long
// 200 response we want to keep reading. .disable_auto_redirect = false.
.disable_auto_redirect = false,
.max_redirection_count = 5,
// HTTPS support via the certificate bundle (harmless for plain http://).
.crt_bundle_attach = esp_crt_bundle_attach,
.keep_alive_enable = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (client) {
lisael_udp_send("RADIO: http perform start");
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
lisael_udp_send("RADIO: http ended err=%s status=%d",
esp_err_to_name(err), status);
if (err != ESP_OK && !s_stop) {
ESP_LOGW(TAG, "http perform ended: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
} else {
lisael_udp_send("RADIO: http client init FAILED");
ESP_LOGE(TAG, "http client init failed");
}
// The network is gone: tell the decoder to wind down too.
s_stop = true;
s_http_running = false;
s_http_task = NULL;
ESP_LOGI(TAG, "http task exit");
vTaskDeleteWithCaps(NULL);
}
// ----------------------------------------------------------------------------
// Decode task: pull MP3 from the StreamBuffer, decode to PCM, write to codec.
// ----------------------------------------------------------------------------
static void decode_task(void *arg)
{
(void)arg;
s_dec_running = true;
register_decoders_once();
esp_audio_simple_dec_cfg_t dcfg = {
.dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3, // FR kids webradios are MP3
.dec_cfg = NULL,
.cfg_size = 0,
};
esp_audio_simple_dec_handle_t dec = NULL;
if (esp_audio_simple_dec_open(&dcfg, &dec) != ESP_AUDIO_ERR_OK) {
ESP_LOGE(TAG, "decoder open failed");
goto done;
}
esp_codec_dev_handle_t codec = lisael_audio_codec();
uint8_t *inbuf = malloc(DEC_READ_CHUNK);
uint32_t out_cap = DEC_OUT_CAP_INIT;
uint8_t *out = malloc(out_cap);
bool codec_open = false;
if (!inbuf || !out || !codec) {
ESP_LOGE(TAG, "decode buffers/codec unavailable");
free(inbuf);
free(out);
if (dec) esp_audio_simple_dec_close(dec);
goto done;
}
ESP_LOGI(TAG, "decode task started");
while (!s_stop) {
// Block until at least 1 byte is available (or timeout). When the HTTP
// task has exited and drained the buffer this returns 0 -> we leave.
size_t n = xStreamBufferReceive(s_net_buf, inbuf, DEC_READ_CHUNK,
pdMS_TO_TICKS(DEC_READ_TIMEOUT_MS));
if (n == 0) {
// No data within the timeout. If the producer is gone, stop;
// otherwise loop and wait again (the receive blocked, no spin).
if (!s_http_running) {
break;
}
continue;
}
esp_audio_simple_dec_raw_t raw = {
.buffer = inbuf,
.len = (uint32_t)n,
.eos = false,
};
while (raw.len > 0 && !s_stop) {
esp_audio_simple_dec_out_t frame = { .buffer = out, .len = out_cap };
esp_audio_err_t e = esp_audio_simple_dec_process(dec, &raw, &frame);
if (e == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
uint8_t *bigger = realloc(out, frame.needed_size);
if (!bigger) { s_stop = true; break; }
out = bigger; out_cap = frame.needed_size;
continue; // retry with a larger PCM buffer
}
if (e != ESP_AUDIO_ERR_OK) {
// A corrupt MP3 frame at a stream boundary is normal; skip this
// input chunk and resync on the next network read.
ESP_LOGD(TAG, "decode err %d (resync)", (int)e);
break;
}
if (frame.decoded_size > 0) {
if (!codec_open) {
// Open the codec at the rate/channels reported by the stream
// on the first decoded PCM, exactly like aac_player.c.
esp_audio_simple_dec_info_t info = {0};
esp_audio_simple_dec_get_info(dec, &info);
ESP_LOGI(TAG, "MP3 %" PRIu32 " Hz %u ch %u bit",
info.sample_rate, info.channel,
info.bits_per_sample);
lisael_udp_send("RADIO: decoded MP3 %" PRIu32 " Hz %u ch %u bit -> opening codec",
info.sample_rate, info.channel, info.bits_per_sample);
esp_codec_dev_close(codec);
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = info.bits_per_sample,
.channel = info.channel,
.sample_rate = info.sample_rate,
};
esp_codec_dev_open(codec, &fs);
esp_codec_dev_set_out_vol(codec,
(float)lisael_audio_get_volume());
codec_open = true;
}
// Blocking write — this is what paces the whole pipeline.
esp_codec_dev_write(codec, frame.buffer, frame.decoded_size);
}
if (raw.consumed == 0 && frame.decoded_size == 0) {
break; // no progress on this chunk; fetch more from the network
}
raw.buffer += raw.consumed;
raw.len -= raw.consumed;
}
}
free(inbuf);
free(out);
if (codec_open) {
esp_codec_dev_close(codec);
}
esp_audio_simple_dec_close(dec);
done:
// Make sure the HTTP task also stops (e.g. when we exited on a decode error).
s_stop = true;
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
}
s_playing = false;
s_dec_running = false;
s_dec_task = NULL;
ESP_LOGI(TAG, "decode task exit");
vTaskDeleteWithCaps(NULL);
}
// ----------------------------------------------------------------------------
// Public API
// ----------------------------------------------------------------------------
esp_err_t lisael_radio_play(const char *url)
{
if (!url) {
return ESP_ERR_INVALID_ARG;
}
// Stop whatever else is playing (and any previous radio session) first.
lisael_radio_stop();
lisael_audio_stop_all();
// Lazily create the network->decoder StreamBuffer IN PSRAM: it is ~32 KB
// and internal RAM is nearly exhausted (xStreamBufferCreate would fail with
// NO_MEM, which is exactly why the radio never started).
if (!s_net_buf) {
s_net_buf = xStreamBufferCreateWithCaps(NET_STREAM_BUF_BYTES, 1,
MALLOC_CAP_SPIRAM);
if (!s_net_buf) {
ESP_LOGE(TAG, "stream buffer alloc failed");
lisael_udp_send("RADIO: stream buffer alloc FAILED");
return ESP_ERR_NO_MEM;
}
} else {
xStreamBufferReset(s_net_buf);
}
strlcpy(s_url, url, sizeof(s_url));
s_stop = false;
s_playing = true;
lisael_audio_set_source(LISAEL_AUDIO_RADIO);
// Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a
// saturated audio path could starve the display. The codec write in the
// decode task blocks, so it yields the CPU regularly; priority 4 keeps it
// responsive without preempting LVGL's own time slice. pin to no core so
// the scheduler can balance with Wi-Fi/LVGL.
BaseType_t ok1 = xTaskCreatePinnedToCoreWithCaps(decode_task, "radio_dec", 8192,
NULL, 4, &s_dec_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192,
NULL, 4, &s_http_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
if (ok1 != pdPASS || ok2 != pdPASS) {
ESP_LOGE(TAG, "task create failed");
s_stop = true;
// Let whichever task did start unwind, then report failure.
lisael_radio_stop();
return ESP_FAIL;
}
ESP_LOGI(TAG, "playing %s", url);
return ESP_OK;
}
void lisael_radio_stop(void)
{
if (!s_http_running && !s_dec_running && !s_playing) {
return; // nothing to do
}
s_stop = true;
// Nudge the decode task in case it is blocked on an empty StreamBuffer.
if (s_net_buf) {
xStreamBufferReset(s_net_buf);
}
// Wait (bounded, ~2 s) for both tasks to release the codec and exit. The
// HTTP perform may take up to its timeout to notice the abort, and the
// decode task may be mid codec write; ~100 * 20 ms covers both.
for (int i = 0; i < 100 && (s_http_running || s_dec_running); i++) {
vTaskDelay(pdMS_TO_TICKS(20));
}
s_playing = false;
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
}
ESP_LOGI(TAG, "stopped");
}
bool lisael_radio_is_playing(void)
{
return s_playing;
}