diff --git a/idf_zacus/components/media_manager/idf_component.yml b/idf_zacus/components/media_manager/idf_component.yml new file mode 100644 index 0000000..90a16d3 --- /dev/null +++ b/idf_zacus/components/media_manager/idf_component.yml @@ -0,0 +1,4 @@ +## Managed dependencies for media_manager. +## Slice P4: helix MP3 decoder for the hotline_tts/*.mp3 cue pool. +dependencies: + chmorgan/esp-libhelix-mp3: "^1.0.3" diff --git a/idf_zacus/components/media_manager/media_manager.c b/idf_zacus/components/media_manager/media_manager.c index 5c2b426..618eeab 100644 --- a/idf_zacus/components/media_manager/media_manager.c +++ b/idf_zacus/components/media_manager/media_manager.c @@ -27,10 +27,13 @@ #include #include #include +#include #include #include #include +#include "mp3dec.h" // P4: helix MP3 decoder (chmorgan/esp-libhelix-mp3) + #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -419,63 +422,139 @@ static void clear_playback_state(void) { s_media.playback_task = NULL; } -static void playback_task(void *arg) { - (void) arg; - char path[MEDIA_PATH_MAX]; - copy_text(path, sizeof(path), s_media.snapshot.playing_path); - - FILE *f = fopen(path, "rb"); +// Stream a 16-bit PCM WAV to the DAC. play_start/play_end bracket the session. +static esp_err_t stream_wav(FILE *f, const char *path) { wav_info_t wav = {0}; - if (f == NULL || !parse_wav_header(f, &wav)) { - if (f) fclose(f); + if (!parse_wav_header(f, &wav)) { ESP_LOGE(TAG, "playback: %s is not a 16-bit PCM WAV", path); - set_last_error("media_play_bad_wav"); - clear_playback_state(); - vTaskDelete(NULL); - return; + return ESP_ERR_INVALID_ARG; } - - ESP_LOGI(TAG, "playback start: %s (%lu Hz, %uch, %lu B) vol=%u", + ESP_LOGI(TAG, "playback start: %s (wav %lu Hz, %uch, %lu B) vol=%u", path, (unsigned long) wav.sample_rate, wav.channels, (unsigned long) wav.data_size, s_media.volume); esp_err_t err = voice_pipeline_play_start(wav.sample_rate, "wav"); if (err != ESP_OK) { - ESP_LOGE(TAG, "play_start failed: %s", esp_err_to_name(err)); - fclose(f); - set_last_error("media_play_i2s"); - clear_playback_state(); - vTaskDelete(NULL); - return; + ESP_LOGW(TAG, "play_start failed: %s", esp_err_to_name(err)); + return err; } uint8_t buf[MEDIA_PLAY_CHUNK_BYTES]; - uint32_t remaining = wav.data_size; - uint32_t written = 0; + uint32_t remaining = wav.data_size, written = 0; while (remaining > 0U && !s_media.playback_stop) { size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf); size_t n = fread(buf, 1, want, f); if (n == 0U) break; remaining -= (uint32_t) n; - size_t pcm = n; if (wav.channels == 2U) { // downmix interleaved stereo16 → mono16 int16_t *s = (int16_t *) buf; size_t frames = n / 4U; - for (size_t i = 0; i < frames; ++i) { + for (size_t i = 0; i < frames; ++i) s[i] = (int16_t) (((int32_t) s[2 * i] + s[2 * i + 1]) / 2); - } pcm = frames * 2U; } apply_volume((int16_t *) buf, pcm / 2U, s_media.volume); if (voice_pipeline_play_chunk(buf, pcm) != ESP_OK) break; written += (uint32_t) pcm; } - voice_pipeline_play_end(); - fclose(f); ESP_LOGI(TAG, "playback done: %s (%lu B to DAC%s)", path, (unsigned long) written, s_media.playback_stop ? ", stopped" : ""); + return ESP_OK; +} + +// Decode and stream an MP3 to the DAC via the helix decoder. Big work buffers +// live on the heap (the decoder also uses a few KB of stack — task is sized +// accordingly). Mono output is downmixed; volume is applied per frame. +static esp_err_t stream_mp3(FILE *f, const char *path) { + HMP3Decoder dec = MP3InitDecoder(); + const size_t IN_SZ = 2048U; + uint8_t *inbuf = malloc(IN_SZ); + int16_t *pcm = malloc(2304 * sizeof(int16_t)); // MAX_NCHAN*MAX_NGRAN*MAX_NSAMP + if (dec == NULL || inbuf == NULL || pcm == NULL) { + if (dec) MP3FreeDecoder(dec); + free(inbuf); free(pcm); + ESP_LOGE(TAG, "mp3: alloc failed"); + return ESP_ERR_NO_MEM; + } + + int bytes_left = 0; + uint8_t *rp = inbuf; + bool started = false, eof = false; + uint32_t written = 0; + esp_err_t result = ESP_OK; + + while (!s_media.playback_stop) { + if (bytes_left < 1024 && !eof) { // refill: keep tail, top up + memmove(inbuf, rp, (size_t) bytes_left); + size_t n = fread(inbuf + bytes_left, 1, IN_SZ - (size_t) bytes_left, f); + if (n == 0U) eof = true; + bytes_left += (int) n; + rp = inbuf; + } + if (bytes_left <= 0) break; + int off = MP3FindSyncWord(rp, bytes_left); + if (off < 0) { if (eof) break; bytes_left = 0; continue; } + rp += off; bytes_left -= off; + int derr = MP3Decode(dec, &rp, &bytes_left, pcm, 0); + if (derr == ERR_MP3_INDATA_UNDERFLOW) { if (eof) break; continue; } + if (derr != ERR_MP3_NONE) continue; // skip a bad frame + MP3FrameInfo info; + MP3GetLastFrameInfo(dec, &info); + if (info.outputSamps <= 0) continue; + if (!started) { + ESP_LOGI(TAG, "playback start: %s (mp3 %d Hz, %dch) vol=%u", + path, info.samprate, info.nChans, s_media.volume); + esp_err_t e = voice_pipeline_play_start((uint32_t) info.samprate, "mp3"); + if (e != ESP_OK) { result = e; break; } + started = true; + } + size_t samps = (size_t) info.outputSamps, bytes; + if (info.nChans == 2) { // downmix stereo16 → mono16 + size_t frames = samps / 2U; + for (size_t i = 0; i < frames; ++i) + pcm[i] = (int16_t) (((int32_t) pcm[2 * i] + pcm[2 * i + 1]) / 2); + bytes = frames * 2U; + } else { + bytes = samps * 2U; + } + apply_volume(pcm, bytes / 2U, s_media.volume); + if (voice_pipeline_play_chunk((uint8_t *) pcm, bytes) != ESP_OK) break; + written += (uint32_t) bytes; + } + + if (started) voice_pipeline_play_end(); + MP3FreeDecoder(dec); + free(inbuf); free(pcm); + ESP_LOGI(TAG, "playback done: %s (mp3, %lu B to DAC%s)", path, + (unsigned long) written, s_media.playback_stop ? ", stopped" : ""); + if (!started && result == ESP_OK) result = ESP_ERR_NOT_SUPPORTED; + return result; +} + +static void playback_task(void *arg) { + (void) arg; + char path[MEDIA_PATH_MAX]; + copy_text(path, sizeof(path), s_media.snapshot.playing_path); + + FILE *f = fopen(path, "rb"); + if (f == NULL) { + ESP_LOGE(TAG, "playback: cannot open %s", path); + set_last_error("media_play_open"); + clear_playback_state(); + vTaskDelete(NULL); + return; + } + + const char *ext = strrchr(path, '.'); + esp_err_t err = (ext && strcasecmp(ext, ".mp3") == 0) + ? stream_mp3(f, path) + : stream_wav(f, path); + fclose(f); + if (err != ESP_OK) { + set_last_error("media_play_failed"); + } clear_playback_state(); vTaskDelete(NULL); } @@ -514,7 +593,7 @@ esp_err_t media_manager_play(const char *path) { s_media.playback_ends_ms = 0U; // real playback: tick must not auto-clear clear_last_error(); - BaseType_t ok = xTaskCreate(playback_task, "media_play", 5120, NULL, 4, + BaseType_t ok = xTaskCreate(playback_task, "media_play", 8192, NULL, 4, &s_media.playback_task); if (ok != pdPASS) { s_media.playback_task = NULL; diff --git a/idf_zacus/components/voice_pipeline/voice_pipeline.c b/idf_zacus/components/voice_pipeline/voice_pipeline.c index 64fee7d..c74ee85 100644 --- a/idf_zacus/components/voice_pipeline/voice_pipeline.c +++ b/idf_zacus/components/voice_pipeline/voice_pipeline.c @@ -21,6 +21,7 @@ #include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/semphr.h" #include "esp_afe_config.h" #include "esp_afe_sr_iface.h" @@ -64,6 +65,7 @@ static struct { i2s_chan_handle_t tx_chan; bool tx_enabled; // channel currently enabled uint32_t tx_sample_rate; // current configured rate + SemaphoreHandle_t play_lock; // serialises TTS vs media playback (one session at a time) voice_state_t state; // capture_task / capture_run removed: mic_broker's task delivers frames // via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it. @@ -241,6 +243,9 @@ static esp_err_t i2s_tx_setup(void) { return err; } s_pipe.tx_enabled = false; + if (s_pipe.play_lock == NULL) { + s_pipe.play_lock = xSemaphoreCreateMutex(); + } return ESP_OK; } @@ -575,6 +580,13 @@ esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) { ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)"); return ESP_ERR_INVALID_STATE; } + // Serialise the shared TX channel: only one playback session (TTS or + // media_manager) may own the DAC at a time. Held until play_end(). + if (s_pipe.play_lock && + xSemaphoreTake(s_pipe.play_lock, pdMS_TO_TICKS(2000)) != pdTRUE) { + ESP_LOGW(TAG, "play_start: busy — another playback owns the I2S TX"); + return ESP_ERR_TIMEOUT; + } // Reconfigure the I2S clock if the bridge announces a different rate. // F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default. if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) { @@ -587,13 +599,17 @@ esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) { if (err != ESP_OK) { ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s", (unsigned) sample_rate, esp_err_to_name(err)); + if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock); return err; } s_pipe.tx_sample_rate = sample_rate; } if (!s_pipe.tx_enabled) { esp_err_t err = i2s_channel_enable(s_pipe.tx_chan); - if (err != ESP_OK) return err; + if (err != ESP_OK) { + if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock); + return err; + } s_pipe.tx_enabled = true; } voice_pipeline_set_state(VOICE_STATE_SPEAKING); @@ -625,5 +641,6 @@ esp_err_t voice_pipeline_play_end(void) { } voice_pipeline_set_state(VOICE_STATE_IDLE); ESP_LOGI(TAG, "play_end"); + if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock); return ESP_OK; } diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults index c4d92ae..e6dfb6d 100644 --- a/idf_zacus/sdkconfig.defaults +++ b/idf_zacus/sdkconfig.defaults @@ -70,3 +70,8 @@ CONFIG_LV_USE_FS_STDIO=y CONFIG_LV_FS_STDIO_LETTER=76 CONFIG_LV_FS_STDIO_PATH="/littlefs" CONFIG_LV_USE_PNG=y + +# P4: FATFS Long File Names (heap) — assets sur SD ont des noms > 8.3 +# (SCENE_WIN.mp3, sonar_hint.mp3, hotline_tts/…). Sans ça fopen échoue. +CONFIG_FATFS_LFN_HEAP=y +CONFIG_FATFS_MAX_LFN=255