diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index e3b3f0c..dd44449 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -15,6 +15,7 @@ idf_component_register( log joltwallet__littlefs puzzle_state + media_manager 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..a3657bb 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -18,6 +18,7 @@ #include "esp_http_server.h" #include "esp_littlefs.h" #include "esp_log.h" +#include "media_manager.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -866,6 +867,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"}. @@ -1084,6 +1134,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 +1212,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/media_manager.c b/idf_zacus/components/media_manager/media_manager.c index 206c4ec..057b56b 100644 --- a/idf_zacus/components/media_manager/media_manager.c +++ b/idf_zacus/components/media_manager/media_manager.c @@ -31,11 +31,18 @@ #include #include +#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 +66,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 ───────────────────────────────────────────────────────────────── @@ -332,6 +341,135 @@ 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; +} + +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"); + wav_info_t wav = {0}; + if (f == NULL || !parse_wav_header(f, &wav)) { + if (f) fclose(f); + 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; + } + + ESP_LOGI(TAG, "playback start: %s (%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; + } + + uint8_t buf[MEDIA_PLAY_CHUNK_BYTES]; + uint32_t remaining = wav.data_size; + uint32_t 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(); + fclose(f); + ESP_LOGI(TAG, "playback done: %s (%lu B to DAC%s)", path, + (unsigned long) written, s_media.playback_stop ? ", stopped" : ""); + 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 +492,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", 5120, 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 +520,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); }