feat(p4): décodeur MP3 (helix) + mutex I2S TTS↔musique + FATFS LFN
CI / platformio (pull_request) Failing after 10m4s
CI / platformio (pull_request) Failing after 10m4s
- voice_pipeline : mutex play_lock pris dans play_start / rendu dans play_end
(+ tous chemins d'erreur). Sérialise TTS et musique sur le canal I2S TX
partagé ; un play_start concurrent attend 2 s puis renvoie BUSY.
- media_manager : décodage MP3 via helix (chmorgan/esp-libhelix-mp3). Refactor
playback_task en dispatcher d'extension → stream_wav / stream_mp3. stream_mp3
décode frame par frame (buffers heap), downmix stéréo→mono, volume, feed I2S.
Stack tâche 5120→8192 (le décodeur consomme de la pile).
- sdkconfig.defaults : CONFIG_FATFS_LFN_HEAP — les assets SD ont des noms > 8.3
(SCENE_WIN.mp3, sonar_hint.mp3…) ; sans LFN fopen échoue ("open failed").
Validé sur Freenove : upload SCENE_WIN.mp3 (510 Ko, stéréo) sur /sdcard →
play → helix décode 22050 Hz 2ch → downmix → I2S MAX98357A. win.mp3 22050 mono
idem. Mutex : TTS et musique ne peuvent plus se chevaucher sur le DAC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
@@ -27,10 +27,13 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user