diff --git a/main/audio/radio_pipeline.c b/main/audio/radio_pipeline.c index 3ee0a32..7b1eb46 100644 --- a/main/audio/radio_pipeline.c +++ b/main/audio/radio_pipeline.c @@ -34,6 +34,7 @@ #include "freertos/task.h" #include "freertos/idf_additions.h" #include "freertos/stream_buffer.h" +#include "freertos/semphr.h" #include "esp_heap_caps.h" #include "esp_log.h" #include "esp_codec_dev.h" @@ -48,8 +49,8 @@ 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 NET_CHUNK_TIMEOUT_MS 200 // short: re-check s_stop fast even when the buffer is full +#define DEC_READ_TIMEOUT_MS 300 // short: the decode task notices a stop promptly #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 @@ -59,11 +60,14 @@ 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 volatile uint32_t s_gen; // bumped per play(); old-session tasks bail on mismatch 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]; +static esp_http_client_handle_t s_client; // live client; closed by stop() to unblock recv +static SemaphoreHandle_t s_client_mux; // guards s_client across tasks // Register the MP3 frame decoder + simple-decoder container parsers once. static void register_decoders_once(void) @@ -88,12 +92,13 @@ static void register_decoders_once(void) 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) { + // Abort if a stop was requested OR a newer session has taken over + // (gen mismatch) — never let an old task become a 2nd StreamBuffer + // sender, which trips a FreeRTOS assert (-> reboot). + if (s_stop || (uint32_t)(intptr_t)evt->user_data != s_gen) { return ESP_FAIL; // abort perform() -> HTTP task returns } size_t sent = xStreamBufferSend(s_net_buf, p, remaining, @@ -101,8 +106,7 @@ static esp_err_t http_event(esp_http_client_event_t *evt) 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. + // stalled / paused) — loop again, re-checking the guards. No spin. } } return ESP_OK; @@ -110,13 +114,13 @@ static esp_err_t http_event(esp_http_client_event_t *evt) 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, + .user_data = arg, // this session's generation (see http_event) .timeout_ms = 15000, .buffer_size = HTTP_RX_BUFFER, // Follow 3xx automatically: RadioKing /play/ endpoints redirect to @@ -131,14 +135,23 @@ static void http_task(void *arg) esp_http_client_handle_t client = esp_http_client_init(&cfg); if (client) { - lisael_udp_send("RADIO: http perform start"); + // Publish the handle so stop() can close the socket and unblock a recv + // that would otherwise sit until the socket timeout. + xSemaphoreTake(s_client_mux, portMAX_DELAY); + s_client = client; + xSemaphoreGive(s_client_mux); + 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)); } + + // Reclaim the handle under the lock before freeing it, so stop() never + // calls close() on a handle we are cleaning up. + xSemaphoreTake(s_client_mux, portMAX_DELAY); + s_client = NULL; + xSemaphoreGive(s_client_mux); esp_http_client_cleanup(client); } else { lisael_udp_send("RADIO: http client init FAILED"); @@ -158,7 +171,7 @@ static void http_task(void *arg) // ---------------------------------------------------------------------------- static void decode_task(void *arg) { - (void)arg; + uint32_t mygen = (uint32_t)(intptr_t)arg; s_dec_running = true; register_decoders_once(); @@ -190,7 +203,7 @@ static void decode_task(void *arg) ESP_LOGI(TAG, "decode task started"); - while (!s_stop) { + while (!s_stop && mygen == s_gen) { // 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, @@ -209,7 +222,7 @@ static void decode_task(void *arg) .len = (uint32_t)n, .eos = false, }; - while (raw.len > 0 && !s_stop) { + while (raw.len > 0 && !s_stop && mygen == s_gen) { 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) { @@ -301,6 +314,23 @@ esp_err_t lisael_radio_play(const char *url) lisael_radio_stop(); lisael_audio_stop_all(); + // Wait until the previous session's tasks are COMPLETELY gone before reusing + // the shared StreamBuffer/codec. Otherwise an old task could still be a + // sender/receiver on the buffer when the new one starts -> two senders -> + // FreeRTOS assert -> reboot (the exact "switch station without Stop" crash). + // The short NET/DEC timeouts make this quick. + for (int i = 0; i < 200 && (s_http_running || s_dec_running); i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (s_http_running || s_dec_running) { + ESP_LOGW(TAG, "previous radio session still winding down — not starting"); + return ESP_FAIL; + } + + if (!s_client_mux) { + s_client_mux = xSemaphoreCreateMutex(); + } + // 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). @@ -319,6 +349,7 @@ esp_err_t lisael_radio_play(const char *url) strlcpy(s_url, url, sizeof(s_url)); s_stop = false; s_playing = true; + uint32_t gen = ++s_gen; // this session's id; old tasks bail on mismatch lisael_audio_set_source(LISAEL_AUDIO_RADIO); // Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a @@ -327,10 +358,10 @@ esp_err_t lisael_radio_play(const char *url) // 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, + (void *)(intptr_t)gen, 4, &s_dec_task, tskNO_AFFINITY, MALLOC_CAP_SPIRAM); BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192, - NULL, 4, &s_http_task, + (void *)(intptr_t)gen, 4, &s_http_task, tskNO_AFFINITY, MALLOC_CAP_SPIRAM); if (ok1 != pdPASS || ok2 != pdPASS) { ESP_LOGE(TAG, "task create failed"); @@ -351,6 +382,16 @@ void lisael_radio_stop(void) } s_stop = true; + // Close the live HTTP socket so a perform() blocked in recv() returns at + // once (otherwise it lingers until the socket timeout, and a fast station + // switch would start a 2nd session on the shared buffer -> assert/reboot). + if (s_client_mux) { + xSemaphoreTake(s_client_mux, portMAX_DELAY); + if (s_client) { + esp_http_client_close(s_client); + } + xSemaphoreGive(s_client_mux); + } // Nudge the decode task in case it is blocked on an empty StreamBuffer. if (s_net_buf) { xStreamBufferReset(s_net_buf);