e35058f53d
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.
365 lines
15 KiB
C
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;
|
|
}
|