Merge pull request 'feat(P4): media_manager vrai playback WAV + /game/media/play' (#9) from feat/p4-media-wav-playback into main
CI / platformio (push) Failing after 3m27s
CI / platformio (push) Failing after 3m27s
This commit was merged in pull request #9.
This commit is contained in:
@@ -15,6 +15,8 @@ idf_component_register(
|
|||||||
log
|
log
|
||||||
joltwallet__littlefs
|
joltwallet__littlefs
|
||||||
puzzle_state
|
puzzle_state
|
||||||
|
media_manager
|
||||||
|
sd_storage
|
||||||
PRIV_REQUIRES
|
PRIV_REQUIRES
|
||||||
local_puzzles
|
local_puzzles
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
#include "esp_littlefs.h"
|
#include "esp_littlefs.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
#include "media_manager.h"
|
||||||
|
#include "sd_storage.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.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/<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 ──────────────────────────────────────────────────
|
// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
|
// 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) {
|
sizeof(path_param)) != ESP_OK) {
|
||||||
return send_error(req, "400 Bad Request", "missing path param");
|
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] == '/') {
|
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 ||
|
const long max_bytes = to_sd ? (8L * 1024 * 1024) : GAME_ENDPOINT_MAX_FILE_BYTES;
|
||||||
req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) {
|
if (req->content_len <= 0 || (long) req->content_len > max_bytes) {
|
||||||
return send_error(req, "400 Bad Request", "size 1..262144 bytes");
|
return send_error(req, "400 Bad Request", "file too large");
|
||||||
}
|
|
||||||
if (mount_storage_lazy() != ESP_OK) {
|
|
||||||
return send_error(req, "503 Service Unavailable", "storage_unavailable");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
char full[160];
|
char full[192];
|
||||||
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
|
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.
|
// mkdir -p for intermediate directories.
|
||||||
for (char *p = full + strlen("/littlefs/"); *p; p++) {
|
for (char *p = full + root_len; *p; p++) {
|
||||||
if (*p == '/') {
|
if (*p == '/') {
|
||||||
*p = '\0';
|
*p = '\0';
|
||||||
mkdir(full, 0775); // EEXIST is fine
|
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,
|
.handler = handle_espnow_cmd_post,
|
||||||
.user_ctx = NULL,
|
.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
|
// 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
|
// (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) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err));
|
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 "
|
ESP_LOGI(TAG, "game endpoint registered "
|
||||||
"(GET+POST /game/group_profile, POST /game/scenario%s, "
|
"(GET+POST /game/group_profile, POST /game/scenario%s, "
|
||||||
|
|||||||
@@ -17,5 +17,7 @@ idf_component_register(
|
|||||||
driver
|
driver
|
||||||
esp_timer
|
esp_timer
|
||||||
esp_system
|
esp_system
|
||||||
|
freertos
|
||||||
|
voice_pipeline
|
||||||
joltwallet__littlefs
|
joltwallet__littlefs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,15 +27,25 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#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_check.h"
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_timer.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";
|
static const char *TAG = "media_manager";
|
||||||
|
|
||||||
#define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music"
|
#define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music"
|
||||||
@@ -59,6 +69,8 @@ static struct {
|
|||||||
uint32_t playback_ends_ms;
|
uint32_t playback_ends_ms;
|
||||||
FILE *recording_file;
|
FILE *recording_file;
|
||||||
uint32_t recording_data_bytes;
|
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;
|
} s_media;
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -149,10 +161,20 @@ static void resolve_play_path(char *out, size_t out_len, const char *path) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path[0] == '/') {
|
if (path[0] == '/') {
|
||||||
copy_text(out, out_len, path);
|
copy_text(out, out_len, path); // absolute: /sdcard/... or /littlefs/...
|
||||||
} else {
|
return;
|
||||||
snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path);
|
|
||||||
}
|
}
|
||||||
|
// 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,
|
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) {
|
esp_err_t media_manager_play(const char *path) {
|
||||||
if (!s_media.initialized) {
|
if (!s_media.initialized) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
@@ -354,15 +581,27 @@ esp_err_t media_manager_play(const char *path) {
|
|||||||
return ESP_ERR_NOT_FOUND;
|
return ESP_ERR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(slice-4+): hand off to the real I2S MP3 decoder here.
|
// Stop any in-flight playback before starting the next cue.
|
||||||
const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL);
|
if (s_media.playback_task != NULL) {
|
||||||
|
(void) media_manager_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
s_media.playback_stop = false;
|
||||||
s_media.snapshot.playing = true;
|
s_media.snapshot.playing = true;
|
||||||
copy_text(s_media.snapshot.playing_path,
|
copy_text(s_media.snapshot.playing_path,
|
||||||
sizeof(s_media.snapshot.playing_path), resolved);
|
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();
|
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;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +609,12 @@ esp_err_t media_manager_stop(void) {
|
|||||||
if (!s_media.initialized) {
|
if (!s_media.initialized) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
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) {
|
if (s_media.snapshot.playing) {
|
||||||
ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path);
|
ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#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
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
#include "esp_mac.h"
|
#include "esp_mac.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
|
||||||
#include "esp_afe_config.h"
|
#include "esp_afe_config.h"
|
||||||
#include "esp_afe_sr_iface.h"
|
#include "esp_afe_sr_iface.h"
|
||||||
@@ -64,6 +65,7 @@ static struct {
|
|||||||
i2s_chan_handle_t tx_chan;
|
i2s_chan_handle_t tx_chan;
|
||||||
bool tx_enabled; // channel currently enabled
|
bool tx_enabled; // channel currently enabled
|
||||||
uint32_t tx_sample_rate; // current configured rate
|
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;
|
voice_state_t state;
|
||||||
// capture_task / capture_run removed: mic_broker's task delivers frames
|
// capture_task / capture_run removed: mic_broker's task delivers frames
|
||||||
// via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it.
|
// 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;
|
return err;
|
||||||
}
|
}
|
||||||
s_pipe.tx_enabled = false;
|
s_pipe.tx_enabled = false;
|
||||||
|
if (s_pipe.play_lock == NULL) {
|
||||||
|
s_pipe.play_lock = xSemaphoreCreateMutex();
|
||||||
|
}
|
||||||
return ESP_OK;
|
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?)");
|
ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)");
|
||||||
return ESP_ERR_INVALID_STATE;
|
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.
|
// 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.
|
// 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) {
|
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) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
|
ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
|
||||||
(unsigned) sample_rate, esp_err_to_name(err));
|
(unsigned) sample_rate, esp_err_to_name(err));
|
||||||
|
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
s_pipe.tx_sample_rate = sample_rate;
|
s_pipe.tx_sample_rate = sample_rate;
|
||||||
}
|
}
|
||||||
if (!s_pipe.tx_enabled) {
|
if (!s_pipe.tx_enabled) {
|
||||||
esp_err_t err = i2s_channel_enable(s_pipe.tx_chan);
|
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;
|
s_pipe.tx_enabled = true;
|
||||||
}
|
}
|
||||||
voice_pipeline_set_state(VOICE_STATE_SPEAKING);
|
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);
|
voice_pipeline_set_state(VOICE_STATE_IDLE);
|
||||||
ESP_LOGI(TAG, "play_end");
|
ESP_LOGI(TAG, "play_end");
|
||||||
|
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ idf_component_register(
|
|||||||
REQUIRES
|
REQUIRES
|
||||||
joltwallet__littlefs
|
joltwallet__littlefs
|
||||||
ota_server
|
ota_server
|
||||||
|
sd_storage
|
||||||
media_manager
|
media_manager
|
||||||
npc_engine
|
npc_engine
|
||||||
hints_client
|
hints_client
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
#include "ota_server.h"
|
#include "ota_server.h"
|
||||||
#include "media_manager.h"
|
#include "media_manager.h"
|
||||||
|
#include "sd_storage.h"
|
||||||
#include "npc_engine.h"
|
#include "npc_engine.h"
|
||||||
#include "hints_client.h"
|
#include "hints_client.h"
|
||||||
#include "voice_pipeline.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) {
|
if (mount_littlefs() == ESP_OK) {
|
||||||
list_littlefs_root();
|
list_littlefs_root();
|
||||||
|
|
||||||
|
|||||||
@@ -70,3 +70,8 @@ CONFIG_LV_USE_FS_STDIO=y
|
|||||||
CONFIG_LV_FS_STDIO_LETTER=76
|
CONFIG_LV_FS_STDIO_LETTER=76
|
||||||
CONFIG_LV_FS_STDIO_PATH="/littlefs"
|
CONFIG_LV_FS_STDIO_PATH="/littlefs"
|
||||||
CONFIG_LV_USE_PNG=y
|
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