// 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 #include #include #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/ 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; }