feat(p4): media_manager — vrai playback WAV + endpoint /game/media/play
CI / platformio (pull_request) Failing after 3m32s
CI / platformio (pull_request) Failing after 3m32s
Remplace le stub de media_manager_play() (qui simulait 2 s) par un vrai
streaming audio :
- parse_wav_header : RIFF/WAVE 16-bit PCM mono/stéréo, positionne sur data.
- playback_task : lit le WAV par chunks et le pousse au MAX98357A via
l'I2S TX de voice_pipeline (play_start/chunk/end), avec downmix stéréo→mono
et volume logiciel. Tâche dédiée (non bloquante), stop coopératif.
- media_manager_stop() attend la fin de la tâche.
Ajoute POST /game/media/play {path} dans game_endpoint pour déclencher une
lecture (gateway/atelier → cue audio), sans passer par une décision NPC.
Validé sur Freenove : upload WAV via /game/file → POST /game/media/play →
play_start sr=16000 → 32000 B streamés en ~1006 ms (cadence DMA temps réel,
= durée du fichier) → play_end. MP3 + montage SD = slices P4 suivantes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ idf_component_register(
|
||||
log
|
||||
joltwallet__littlefs
|
||||
puzzle_state
|
||||
media_manager
|
||||
PRIV_REQUIRES
|
||||
local_puzzles
|
||||
)
|
||||
|
||||
@@ -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/<id>/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, "
|
||||
|
||||
@@ -17,5 +17,7 @@ idf_component_register(
|
||||
driver
|
||||
esp_timer
|
||||
esp_system
|
||||
freertos
|
||||
voice_pipeline
|
||||
joltwallet__littlefs
|
||||
)
|
||||
|
||||
@@ -31,11 +31,18 @@
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user