diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index e3b3f0c..7b95817 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -15,6 +15,8 @@ idf_component_register( log joltwallet__littlefs puzzle_state + media_manager + sd_storage PRIV_REQUIRES local_puzzles ) diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 26b7d31..85b4e6e 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -18,6 +18,8 @@ #include "esp_http_server.h" #include "esp_littlefs.h" #include "esp_log.h" +#include "media_manager.h" +#include "sd_storage.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -866,6 +868,55 @@ static esp_err_t handle_step_post(httpd_req_t *req) { } } +// ─── POST /game/media/play — trigger a media_manager WAV cue ───────────────── +// Body: { "path": "apps//cue.wav" | "/littlefs/music/foo.wav" }. Streams +// the file to the MAX98357A via media_manager (P4 real playback). Lets the +// gateway / atelier fire audio cues without an NPC decision. +static esp_err_t handle_media_play_post(httpd_req_t *req) { + char body[513] = {0}; + if (req->content_len <= 0 || req->content_len > 512) { + return send_error(req, "400 Bad Request", "body must be 1..512 bytes"); + } + int total = 0; + while (total < (int) req->content_len) { + int got = httpd_req_recv(req, body + total, req->content_len - total); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + return send_error(req, "400 Bad Request", "recv failed"); + } + total += got; + } + body[total] = '\0'; + + cJSON *root = cJSON_Parse(body); + if (!root) { + return send_error(req, "400 Bad Request", "malformed json"); + } + const cJSON *path_j = cJSON_GetObjectItemCaseSensitive(root, "path"); + if (!cJSON_IsString(path_j) || !path_j->valuestring || path_j->valuestring[0] == '\0') { + cJSON_Delete(root); + return send_error(req, "400 Bad Request", "missing or empty path"); + } + char path[160]; + strncpy(path, path_j->valuestring, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + cJSON_Delete(root); + + esp_err_t err = media_manager_play(path); + if (err == ESP_OK) { + char buf[200]; + snprintf(buf, sizeof(buf), "{\"status\":\"playing\",\"path\":\"%s\"}", path); + return send_json(req, "200 OK", buf); + } + if (err == ESP_ERR_NOT_FOUND) { + return send_error(req, "404 Not Found", "file_not_found"); + } + if (err == ESP_ERR_INVALID_ARG) { + return send_error(req, "400 Bad Request", "invalid_path"); + } + return send_error(req, "500 Internal Server Error", esp_err_to_name(err)); +} + // ─── GET /game/puzzle_state ────────────────────────────────────────────────── // // Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}. @@ -951,22 +1002,36 @@ static esp_err_t handle_file_post(httpd_req_t *req) { sizeof(path_param)) != ESP_OK) { return send_error(req, "400 Bad Request", "missing path param"); } - if (strncmp(path_param, "apps/", 5) != 0 || strstr(path_param, "..") || + // Route: apps/<…> → /littlefs/apps/<…> (display shell tiles, ≤256 KiB); + // sd/<…> → /sdcard/<…> (P4 large assets: audio, images). + const bool to_sd = (strncmp(path_param, "sd/", 3) == 0); + const bool to_apps = (strncmp(path_param, "apps/", 5) == 0); + if ((!to_sd && !to_apps) || strstr(path_param, "..") || path_param[strlen(path_param) - 1] == '/') { - return send_error(req, "403 Forbidden", "path must be under apps/"); + return send_error(req, "403 Forbidden", "path must be under apps/ or sd/"); } - if (req->content_len <= 0 || - req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) { - return send_error(req, "400 Bad Request", "size 1..262144 bytes"); - } - if (mount_storage_lazy() != ESP_OK) { - return send_error(req, "503 Service Unavailable", "storage_unavailable"); + const long max_bytes = to_sd ? (8L * 1024 * 1024) : GAME_ENDPOINT_MAX_FILE_BYTES; + if (req->content_len <= 0 || (long) req->content_len > max_bytes) { + return send_error(req, "400 Bad Request", "file too large"); } - char full[160]; - snprintf(full, sizeof(full), "/littlefs/%s", path_param); + char full[192]; + size_t root_len; + if (to_sd) { + if (!sd_storage_ready()) { + return send_error(req, "503 Service Unavailable", "sd_not_mounted"); + } + snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3); // strip "sd/" + root_len = strlen("/sdcard/"); + } else { + if (mount_storage_lazy() != ESP_OK) { + return send_error(req, "503 Service Unavailable", "storage_unavailable"); + } + snprintf(full, sizeof(full), "/littlefs/%s", path_param); + root_len = strlen("/littlefs/"); + } // mkdir -p for intermediate directories. - for (char *p = full + strlen("/littlefs/"); *p; p++) { + for (char *p = full + root_len; *p; p++) { if (*p == '/') { *p = '\0'; mkdir(full, 0775); // EEXIST is fine @@ -1084,6 +1149,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .handler = handle_espnow_cmd_post, .user_ctx = NULL, }; + static const httpd_uri_t uri_media_play_post = { + .uri = "/game/media/play", + .method = HTTP_POST, + .handler = handle_media_play_post, + .user_ctx = NULL, + }; // Bring up the ESP-NOW mesh transport. The master is primarily a sender // (the relay handler) but we also register the apply adapter so a peer @@ -1156,6 +1227,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { if (err != ESP_OK) { ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err)); } + err = httpd_register_uri_handler(server, &uri_media_play_post); + if (err != ESP_OK) { + ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err)); + } ESP_LOGI(TAG, "game endpoint registered " "(GET+POST /game/group_profile, POST /game/scenario%s, " diff --git a/idf_zacus/components/media_manager/CMakeLists.txt b/idf_zacus/components/media_manager/CMakeLists.txt index fa9c79a..bd3a38a 100644 --- a/idf_zacus/components/media_manager/CMakeLists.txt +++ b/idf_zacus/components/media_manager/CMakeLists.txt @@ -17,5 +17,7 @@ idf_component_register( driver esp_timer esp_system + freertos + voice_pipeline joltwallet__littlefs ) 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 206c4ec..618eeab 100644 --- a/idf_zacus/components/media_manager/media_manager.c +++ b/idf_zacus/components/media_manager/media_manager.c @@ -27,15 +27,25 @@ #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" + #include "esp_check.h" #include "esp_err.h" #include "esp_log.h" #include "esp_timer.h" +// Slice P4: real WAV playback streams 16-bit PCM to the MAX98357A over the +// I2S TX channel already owned by voice_pipeline (shared DAC, no codec init). +#include "voice_pipeline.h" + static const char *TAG = "media_manager"; #define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music" @@ -59,6 +69,8 @@ static struct { uint32_t playback_ends_ms; FILE *recording_file; uint32_t recording_data_bytes; + TaskHandle_t playback_task; // P4: real WAV streamer, NULL when idle + volatile bool playback_stop; // request the streamer to abort } s_media; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -149,10 +161,20 @@ static void resolve_play_path(char *out, size_t out_len, const char *path) { return; } if (path[0] == '/') { - copy_text(out, out_len, path); - } else { - snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path); + copy_text(out, out_len, path); // absolute: /sdcard/... or /littlefs/... + return; } + // Relative cue: prefer the microSD copy when present (P4 — large asset + // store), else fall back to the LittleFS music_dir. file_exists() returns + // false when no card is mounted, so this is transparent without a hard + // dependency on sd_storage. + char sd_try[MEDIA_PATH_MAX]; + snprintf(sd_try, sizeof(sd_try), "/sdcard/music/%s", path); + if (file_exists(sd_try)) { + copy_text(out, out_len, sd_try); + return; + } + snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path); } static void sanitize_filename(char *out, size_t out_len, @@ -332,6 +354,211 @@ void media_manager_note_step_change(void) { } } +// ─── P4: real WAV playback over the shared MAX98357A I2S TX ────────────────── +// +// Streams 16-bit PCM WAV from LittleFS to the DAC via voice_pipeline's TX +// channel. WAV (not MP3) keeps this decoder-free; MP3 stays a later slice. +// NOTE: shares the I2S TX with TTS — callers must not overlap a `speak_*` +// exchange with music playback (the NPC coordination layer already serialises +// cues). A TX mutex is the natural follow-up. + +#define MEDIA_PLAY_CHUNK_BYTES 1024U + +typedef struct { + uint32_t sample_rate; + uint16_t channels; + uint16_t bits; + uint32_t data_size; // PCM bytes in the data chunk +} wav_info_t; + +static uint32_t rd_u32(const uint8_t *p) { + return (uint32_t) p[0] | ((uint32_t) p[1] << 8) | + ((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24); +} + +// Parse a RIFF/WAVE header, leaving `f` positioned at the PCM data. Accepts +// 16-bit PCM mono/stereo only; returns false otherwise. +static bool parse_wav_header(FILE *f, wav_info_t *out) { + uint8_t riff[12]; + if (fread(riff, 1, 12, f) != 12) return false; + if (memcmp(riff, "RIFF", 4) != 0 || memcmp(riff + 8, "WAVE", 4) != 0) return false; + + bool have_fmt = false; + uint8_t ck[8]; + while (fread(ck, 1, 8, f) == 8) { + uint32_t csz = rd_u32(ck + 4); + if (memcmp(ck, "fmt ", 4) == 0) { + uint8_t fmt[16]; + uint32_t want = csz < sizeof(fmt) ? csz : (uint32_t) sizeof(fmt); + if (fread(fmt, 1, want, f) != want) return false; + if (((uint16_t) fmt[0] | ((uint16_t) fmt[1] << 8)) != 1U) return false; // PCM + out->channels = (uint16_t) fmt[2] | ((uint16_t) fmt[3] << 8); + out->sample_rate = rd_u32(fmt + 4); + out->bits = (uint16_t) fmt[14] | ((uint16_t) fmt[15] << 8); + if (csz > want) fseek(f, (long) (csz - want), SEEK_CUR); + have_fmt = true; + } else if (memcmp(ck, "data", 4) == 0) { + out->data_size = csz; + return have_fmt && out->bits == 16U && + (out->channels == 1U || out->channels == 2U); + } else { + fseek(f, (long) (csz + (csz & 1U)), SEEK_CUR); // skip + word-align pad + } + } + return false; +} + +static void apply_volume(int16_t *s, size_t count, uint8_t volume) { + if (volume >= 100U) return; + for (size_t i = 0; i < count; ++i) { + s[i] = (int16_t) (((int32_t) s[i] * (int32_t) volume) / 100); + } +} + +static void clear_playback_state(void) { + s_media.snapshot.playing = false; + s_media.snapshot.playing_path[0] = '\0'; + s_media.playback_ends_ms = 0U; + s_media.playback_task = NULL; +} + +// 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 (!parse_wav_header(f, &wav)) { + ESP_LOGE(TAG, "playback: %s is not a 16-bit PCM WAV", path); + return ESP_ERR_INVALID_ARG; + } + 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_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, 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) + 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(); + 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); +} + esp_err_t media_manager_play(const char *path) { if (!s_media.initialized) { return ESP_ERR_INVALID_STATE; @@ -354,15 +581,27 @@ esp_err_t media_manager_play(const char *path) { return ESP_ERR_NOT_FOUND; } - // TODO(slice-4+): hand off to the real I2S MP3 decoder here. - const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL); + // Stop any in-flight playback before starting the next cue. + if (s_media.playback_task != NULL) { + (void) media_manager_stop(); + } + + s_media.playback_stop = false; s_media.snapshot.playing = true; copy_text(s_media.snapshot.playing_path, sizeof(s_media.snapshot.playing_path), resolved); - s_media.playback_ends_ms = now_ms + MEDIA_STUB_PLAYBACK_MS; + s_media.playback_ends_ms = 0U; // real playback: tick must not auto-clear clear_last_error(); - ESP_LOGI(TAG, "playing %s (simulated %ums) vol=%u", - resolved, MEDIA_STUB_PLAYBACK_MS, s_media.volume); + + BaseType_t ok = xTaskCreate(playback_task, "media_play", 8192, NULL, 4, + &s_media.playback_task); + if (ok != pdPASS) { + s_media.playback_task = NULL; + s_media.snapshot.playing = false; + s_media.snapshot.playing_path[0] = '\0'; + set_last_error("media_play_task"); + return ESP_ERR_NO_MEM; + } return ESP_OK; } @@ -370,6 +609,12 @@ esp_err_t media_manager_stop(void) { if (!s_media.initialized) { return ESP_ERR_INVALID_STATE; } + if (s_media.playback_task != NULL) { + s_media.playback_stop = true; + for (int i = 0; i < 100 && s_media.playback_task != NULL; ++i) { + vTaskDelay(pdMS_TO_TICKS(10)); // let the streamer drain + self-delete + } + } if (s_media.snapshot.playing) { ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path); } diff --git a/idf_zacus/components/sd_storage/CMakeLists.txt b/idf_zacus/components/sd_storage/CMakeLists.txt new file mode 100644 index 0000000..a670065 --- /dev/null +++ b/idf_zacus/components/sd_storage/CMakeLists.txt @@ -0,0 +1,11 @@ +## Zacus sd_storage — microSD (SDMMC 1-bit) FAT mount at /sdcard (P4). +idf_component_register( + SRCS + "sd_storage.c" + INCLUDE_DIRS + "." + REQUIRES + fatfs + sdmmc + esp_driver_sdmmc +) diff --git a/idf_zacus/components/sd_storage/sd_storage.c b/idf_zacus/components/sd_storage/sd_storage.c new file mode 100644 index 0000000..95888e4 --- /dev/null +++ b/idf_zacus/components/sd_storage/sd_storage.c @@ -0,0 +1,74 @@ +#include "sd_storage.h" + +#include "esp_log.h" +#include "esp_vfs_fat.h" +#include "sdmmc_cmd.h" +#include "driver/sdmmc_host.h" + +static const char *TAG = "sd_storage"; + +// Freenove ESP32-S3 WROOM N16R8 microSD (SDMMC 1-bit). Mirrors +// ui_freenove_allinone/include/ui_freenove_config.h FREENOVE_SDMMC_*. +#define SD_PIN_CMD 38 +#define SD_PIN_CLK 39 +#define SD_PIN_D0 40 + +static sdmmc_card_t *s_card = NULL; + +esp_err_t sd_storage_mount(void) { + if (s_card != NULL) { + return ESP_OK; + } + + const esp_vfs_fat_sdmmc_mount_config_t mount_cfg = { + .format_if_mount_failed = false, // never wipe a user's card + .max_files = 5, + .allocation_unit_size = 16 * 1024, + }; + + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.flags = SDMMC_HOST_FLAG_1BIT; // Freenove wires D0 only + host.max_freq_khz = SDMMC_FREQ_DEFAULT; + + sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT(); + slot.width = 1; + slot.clk = SD_PIN_CLK; + slot.cmd = SD_PIN_CMD; + slot.d0 = SD_PIN_D0; + slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + + esp_err_t err = esp_vfs_fat_sdmmc_mount(SD_STORAGE_MOUNT_POINT, &host, + &slot, &mount_cfg, &s_card); + if (err != ESP_OK) { + ESP_LOGW(TAG, "mount failed: %s — no card inserted or wiring issue", + esp_err_to_name(err)); + s_card = NULL; + return err; + } + + ESP_LOGI(TAG, "microSD mounted at %s — %lu MiB (%s)", + SD_STORAGE_MOUNT_POINT, (unsigned long) sd_storage_capacity_mb(), + s_card->cid.name); + return ESP_OK; +} + +esp_err_t sd_storage_unmount(void) { + if (s_card == NULL) { + return ESP_OK; + } + esp_err_t err = esp_vfs_fat_sdcard_unmount(SD_STORAGE_MOUNT_POINT, s_card); + s_card = NULL; + return err; +} + +bool sd_storage_ready(void) { + return s_card != NULL; +} + +uint32_t sd_storage_capacity_mb(void) { + if (s_card == NULL) { + return 0U; + } + uint64_t bytes = (uint64_t) s_card->csd.capacity * s_card->csd.sector_size; + return (uint32_t) (bytes / (1024ULL * 1024ULL)); +} diff --git a/idf_zacus/components/sd_storage/sd_storage.h b/idf_zacus/components/sd_storage/sd_storage.h new file mode 100644 index 0000000..f474f06 --- /dev/null +++ b/idf_zacus/components/sd_storage/sd_storage.h @@ -0,0 +1,35 @@ +// Zacus sd_storage — microSD mount for the Freenove ESP32-S3 master. +// +// The Freenove WROOM N16R8 exposes the card on the SDMMC peripheral in +// 1-bit mode (CMD=GPIO38, CLK=GPIO39, D0=GPIO40 — mirrors the Arduino +// `ui_freenove_allinone` FREENOVE_SDMMC_* pinout). Mounts FAT at /sdcard so +// media_manager and the asset endpoints can stage large assets off the 5 MB +// LittleFS partition (P4). Mount is best-effort: a missing card is not fatal. +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define SD_STORAGE_MOUNT_POINT "/sdcard" + +// Mount the microSD card (SDMMC 1-bit) at /sdcard. Returns ESP_OK on success; +// ESP_ERR_NOT_FOUND / ESP_FAIL when no card is present or wiring is wrong. +esp_err_t sd_storage_mount(void); + +// Unmount and release the card. +esp_err_t sd_storage_unmount(void); + +// True once a card is mounted. +bool sd_storage_ready(void); + +// Card capacity in MiB (0 when not mounted). +uint32_t sd_storage_capacity_mb(void); + +#ifdef __cplusplus +} +#endif 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/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 0d9a88c..b4758cf 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -6,6 +6,7 @@ idf_component_register( REQUIRES joltwallet__littlefs ota_server + sd_storage media_manager npc_engine hints_client diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 519446e..3b72fd1 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -40,6 +40,7 @@ #include "ota_server.h" #include "media_manager.h" +#include "sd_storage.h" #include "npc_engine.h" #include "hints_client.h" #include "voice_pipeline.h" @@ -484,6 +485,13 @@ void app_main(void) { } } + // P4: best-effort microSD mount (SDMMC 1-bit on /sdcard). A missing card + // is non-fatal — assets fall back to LittleFS. + if (sd_storage_mount() == ESP_OK) { + ESP_LOGI(TAG, "microSD ready (%lu MiB) at %s", + (unsigned long) sd_storage_capacity_mb(), SD_STORAGE_MOUNT_POINT); + } + if (mount_littlefs() == ESP_OK) { list_littlefs_root(); 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