feat(box3): real screen rendering + WAV I2S playback for CMD executor
CI / platformio (pull_request) Failing after 9m25s
CI / platformio (pull_request) Failing after 9m25s
Objective 1 — screen: full-scene faithful rendering on 320x240 LVGL display.
Palette matches idf_zacus display_ui reference: bg #0055AA (Workbench blue),
symbol #FF8800 (orange / font-48), title #FFFFFF (font-24 top), subtitle
#AAAAAA (font-14 bottom). All four effects implemented:
- pulse: LVGL anim opacity COVER→50 breathing on symbol (600ms half-period)
- glitch: lv_timer 120ms flicker + X-jitter on title
- gyro: lv_arc rotating ring around symbol (1200ms full rotation)
- none: static
Objective 2 — play: real WAV PCM-16 decode + I2S streaming.
- SD card mounted best-effort via bsp_sdcard_mount() on first play CMD
- WAV header parser (chunk-walker, tolerant of non-standard ordering)
- PCM samples streamed via i2s_channel_write (same s_spk_handle as TTS)
- Embedded test cue (C5-E5-G5, 570ms, 16kHz mono, ~18 KB) baked into
embedded_wav.h — proves real WAV decode + I2S path without SD card
- Graceful fallback chain: SD file → embedded cue → 880 Hz beep
main.c: cmd_exec_set_spk_handle() called after speaker_init() to pass the
I2S TX channel handle to the CMD executor.
Tested on ESP32-S3-BOX-3 (/dev/cu.usbmodem11301):
- All four screen effects confirmed via serial log + visual observation
- play embedded://cue streams 18240 bytes PCM at 16kHz confirmed
- SD mount succeeds (card present), file not found → embedded fallback OK
- No crashes, existing voice pipeline and scenario server unaffected
This commit is contained in:
+443
-51
@@ -1,77 +1,187 @@
|
||||
// cmd_exec.c — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
|
||||
// See cmd_exec.h for the wire format spec.
|
||||
//
|
||||
// Objective 1 — screen: full-scene faithful rendering
|
||||
// Palette: bg #0055AA / symbol #FF8800 / title #FFFFFF / subtitle #AAAAAA
|
||||
// Effects: pulse (opacity breathing on symbol via LVGL anim)
|
||||
// glitch (rapid opacity flicker on title via lv_timer)
|
||||
// gyro (rotating arc ring around symbol)
|
||||
// none (static)
|
||||
//
|
||||
// Objective 2 — play: real WAV streaming to I2S
|
||||
// 1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
|
||||
// 2. Stream PCM samples via s_spk_handle (extern, same I2S channel as TTS).
|
||||
// 3. If SD absent / file not found → fallback bip + log.
|
||||
// 4. Embedded test cue always available at virtual path "embedded://" or
|
||||
// when the requested path starts with "embedded:" — proves WAV decode + I2S.
|
||||
|
||||
#include "cmd_exec.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "bsp/esp-bsp.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_vfs_fat.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
// audio_play_tone is defined in main.c and declared here to avoid a circular
|
||||
// header dependency. It plays a sine tone on the speaker (blocking).
|
||||
#include "embedded_wav.h" // s_embedded_wav[], EMBEDDED_WAV_SIZE
|
||||
|
||||
// audio_play_tone and s_spk_handle are defined in main.c
|
||||
extern void audio_play_tone(float frequency, int duration_ms);
|
||||
|
||||
// Speaker I2S handle — obtained from main.c via a weak accessor.
|
||||
// We declare a weak symbol here; main.c must define cmd_exec_set_spk_handle()
|
||||
// and call it after speaker_init(). Alternatively we use the driver directly.
|
||||
#include "driver/i2s_std.h"
|
||||
static i2s_chan_handle_t s_spk_handle_local = NULL;
|
||||
|
||||
void cmd_exec_set_spk_handle(i2s_chan_handle_t h)
|
||||
{
|
||||
s_spk_handle_local = h;
|
||||
}
|
||||
|
||||
#define TAG "cmd_exec"
|
||||
|
||||
// ─── screen overlay ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// We maintain a single full-screen overlay that appears on `screen` CMDs.
|
||||
// It is built lazily on first use so the display lock is only taken when a CMD
|
||||
// actually arrives.
|
||||
// ─── Palette (matches idf_zacus/components/display_ui reference) ─────────────
|
||||
#define COL_BG 0x0055AA // Workbench blue
|
||||
#define COL_SYMBOL 0xFF8800 // Workbench orange
|
||||
#define COL_TITLE 0xFFFFFF // white
|
||||
#define COL_SUB 0xAAAAAA // mid grey
|
||||
|
||||
#define SCREEN_LOCK_MS 1000
|
||||
|
||||
static lv_obj_t *s_overlay = NULL;
|
||||
static lv_obj_t *s_title_lbl = NULL;
|
||||
static lv_obj_t *s_sub_lbl = NULL;
|
||||
static lv_obj_t *s_sym_lbl = NULL;
|
||||
// ─── Screen overlay state ─────────────────────────────────────────────────────
|
||||
|
||||
static lv_obj_t *s_overlay = NULL;
|
||||
static lv_obj_t *s_title_lbl = NULL;
|
||||
static lv_obj_t *s_sub_lbl = NULL;
|
||||
static lv_obj_t *s_sym_lbl = NULL;
|
||||
static lv_obj_t *s_gyro_arc = NULL;
|
||||
|
||||
// Active animations / timers (so we can stop them on next call)
|
||||
static lv_timer_t *s_glitch_timer = NULL;
|
||||
|
||||
// ─── Effect callbacks ─────────────────────────────────────────────────────────
|
||||
|
||||
static void pulse_anim_cb(void *obj, int32_t v)
|
||||
{
|
||||
lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)v, 0);
|
||||
}
|
||||
|
||||
static void gyro_anim_cb(void *obj, int32_t v)
|
||||
{
|
||||
lv_arc_set_end_angle((lv_obj_t *)obj, (uint16_t)(v % 360));
|
||||
}
|
||||
|
||||
static void glitch_timer_cb(lv_timer_t *t)
|
||||
{
|
||||
(void)t;
|
||||
static bool flip = false;
|
||||
flip = !flip;
|
||||
lv_obj_set_style_opa(s_title_lbl,
|
||||
flip ? LV_OPA_20 : LV_OPA_COVER, 0);
|
||||
// Slight X jitter: move label 0 or 3 pixels right alternately
|
||||
lv_obj_set_x(s_title_lbl, flip ? 3 : 0);
|
||||
}
|
||||
|
||||
// ─── Stop all active effects ──────────────────────────────────────────────────
|
||||
|
||||
static void stop_effects_locked(void)
|
||||
{
|
||||
// Stop pulse animation on symbol
|
||||
lv_anim_del(s_sym_lbl, pulse_anim_cb);
|
||||
if (s_sym_lbl) {
|
||||
lv_obj_set_style_opa(s_sym_lbl, LV_OPA_COVER, 0);
|
||||
}
|
||||
|
||||
// Stop glitch timer
|
||||
if (s_glitch_timer) {
|
||||
lv_timer_pause(s_glitch_timer);
|
||||
}
|
||||
if (s_title_lbl) {
|
||||
lv_obj_set_style_opa(s_title_lbl, LV_OPA_COVER, 0);
|
||||
lv_obj_set_x(s_title_lbl, 0);
|
||||
}
|
||||
|
||||
// Stop gyro arc
|
||||
if (s_gyro_arc) {
|
||||
lv_anim_del(s_gyro_arc, gyro_anim_cb);
|
||||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build overlay (lazy, first CMD only) ────────────────────────────────────
|
||||
|
||||
static void build_overlay_locked(void)
|
||||
{
|
||||
if (s_overlay) return; // already built
|
||||
if (s_overlay) return;
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
|
||||
// Semi-transparent dark panel over whatever is already on screen.
|
||||
// Full-screen panel with Workbench blue background
|
||||
s_overlay = lv_obj_create(scr);
|
||||
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x0a0a1a), 0);
|
||||
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_90, 0);
|
||||
lv_obj_set_pos(s_overlay, 0, 0);
|
||||
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(s_overlay, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_overlay, 12, 0);
|
||||
lv_obj_set_flex_flow(s_overlay, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(s_overlay, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
s_sym_lbl = lv_label_create(s_overlay);
|
||||
lv_label_set_text(s_sym_lbl, "");
|
||||
lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
|
||||
lv_obj_set_style_text_color(s_sym_lbl, lv_palette_main(LV_PALETTE_CYAN), 0);
|
||||
lv_obj_set_style_pad_bottom(s_sym_lbl, 8, 0);
|
||||
lv_obj_set_style_radius(s_overlay, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_overlay, 10, 0);
|
||||
|
||||
// Title — top, white, font 24
|
||||
s_title_lbl = lv_label_create(s_overlay);
|
||||
lv_label_set_text(s_title_lbl, "");
|
||||
lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_24, 0);
|
||||
lv_obj_set_style_text_color(s_title_lbl, lv_color_white(), 0);
|
||||
lv_obj_set_style_pad_bottom(s_title_lbl, 6, 0);
|
||||
lv_obj_set_style_text_color(s_title_lbl, lv_color_hex(COL_TITLE), 0);
|
||||
lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(s_title_lbl, LV_PCT(90));
|
||||
lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
|
||||
|
||||
// Symbol — center, orange, font 48
|
||||
s_sym_lbl = lv_label_create(s_overlay);
|
||||
lv_label_set_text(s_sym_lbl, "");
|
||||
lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
|
||||
lv_obj_set_style_text_color(s_sym_lbl, lv_color_hex(COL_SYMBOL), 0);
|
||||
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
|
||||
|
||||
// Gyro arc: hidden rotating ring around symbol (gyro effect)
|
||||
s_gyro_arc = lv_arc_create(s_overlay);
|
||||
lv_obj_set_size(s_gyro_arc, 90, 90);
|
||||
lv_obj_align(s_gyro_arc, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_arc_set_rotation(s_gyro_arc, 270);
|
||||
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
|
||||
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(COL_SYMBOL), LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_width(s_gyro_arc, 4, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(s_gyro_arc, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(s_gyro_arc, 0, 0);
|
||||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Subtitle — bottom, grey, font 14
|
||||
s_sub_lbl = lv_label_create(s_overlay);
|
||||
lv_label_set_text(s_sub_lbl, "");
|
||||
lv_obj_set_style_text_font(s_sub_lbl, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(s_sub_lbl, lv_palette_lighten(LV_PALETTE_GREY, 1), 0);
|
||||
lv_obj_set_style_text_color(s_sub_lbl, lv_color_hex(COL_SUB), 0);
|
||||
lv_label_set_long_mode(s_sub_lbl, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(s_sub_lbl, LV_PCT(90));
|
||||
lv_obj_set_style_text_align(s_sub_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
|
||||
|
||||
// Glitch timer: created paused, resumed by glitch effect
|
||||
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
|
||||
lv_timer_pause(s_glitch_timer);
|
||||
}
|
||||
|
||||
// op=screen: {"t":"title","u":"subtitle","y":"symbol","e":"effect"}
|
||||
// All keys are optional — present = set, absent = leave empty.
|
||||
// ─── op=screen ────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t exec_screen(const cJSON *a)
|
||||
{
|
||||
const cJSON *title = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
|
||||
@@ -82,7 +192,7 @@ static esp_err_t exec_screen(const cJSON *a)
|
||||
const char *t_str = cJSON_IsString(title) ? title->valuestring : "";
|
||||
const char *u_str = cJSON_IsString(sub) ? sub->valuestring : "";
|
||||
const char *y_str = cJSON_IsString(sym) ? sym->valuestring : "";
|
||||
const char *e_str = cJSON_IsString(eff) ? eff->valuestring : "";
|
||||
const char *e_str = cJSON_IsString(eff) ? eff->valuestring : "none";
|
||||
|
||||
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" u=\"%s\" y=\"%s\" e=\"%s\"",
|
||||
t_str, u_str, y_str, e_str);
|
||||
@@ -93,45 +203,326 @@ static esp_err_t exec_screen(const cJSON *a)
|
||||
}
|
||||
|
||||
build_overlay_locked();
|
||||
stop_effects_locked();
|
||||
|
||||
// Update labels
|
||||
lv_label_set_text(s_title_lbl, t_str);
|
||||
lv_label_set_text(s_sub_lbl, u_str);
|
||||
lv_label_set_text(s_sym_lbl, y_str);
|
||||
lv_label_set_text(s_sub_lbl, u_str);
|
||||
|
||||
// Bring overlay to top so it is visible over plip_ui widgets.
|
||||
// Re-align after text update
|
||||
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
|
||||
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
|
||||
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
|
||||
|
||||
// Bring overlay to front
|
||||
lv_obj_move_foreground(s_overlay);
|
||||
|
||||
// Simple pulse effect: briefly tint the background for "pulse"/"flash".
|
||||
if (strcmp(e_str, "pulse") == 0 || strcmp(e_str, "flash") == 0) {
|
||||
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x1a1a3a), 0);
|
||||
// Apply effect
|
||||
if (strcmp(e_str, "pulse") == 0) {
|
||||
// Opacity breathing: COVER → 50 → COVER, 600ms per half
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, s_sym_lbl);
|
||||
lv_anim_set_exec_cb(&a, pulse_anim_cb);
|
||||
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
|
||||
lv_anim_set_time(&a, 600);
|
||||
lv_anim_set_playback_time(&a, 600);
|
||||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||||
lv_anim_start(&a);
|
||||
ESP_LOGI(TAG, "screen: pulse effect started");
|
||||
|
||||
} else if (strcmp(e_str, "glitch") == 0) {
|
||||
// Rapid opacity + X-jitter on title
|
||||
if (s_glitch_timer) {
|
||||
lv_timer_resume(s_glitch_timer);
|
||||
}
|
||||
ESP_LOGI(TAG, "screen: glitch effect started");
|
||||
|
||||
} else if (strcmp(e_str, "gyro") == 0) {
|
||||
// Rotating arc ring around the symbol
|
||||
if (s_gyro_arc) {
|
||||
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, s_gyro_arc);
|
||||
lv_anim_set_exec_cb(&a, gyro_anim_cb);
|
||||
lv_anim_set_values(&a, 0, 360);
|
||||
lv_anim_set_time(&a, 1200);
|
||||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||||
lv_anim_start(&a);
|
||||
}
|
||||
ESP_LOGI(TAG, "screen: gyro effect started");
|
||||
|
||||
} else {
|
||||
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x0a0a1a), 0);
|
||||
// "none" or unrecognised → static
|
||||
ESP_LOGI(TAG, "screen: static (no effect)");
|
||||
}
|
||||
|
||||
bsp_display_unlock();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// op=play: {"p":"<path>","l":0|1}
|
||||
// Real file playback is not available on BOX-3 (no media_manager in box3_voice).
|
||||
// We emit a 440 Hz beep as an audible acknowledgment and log the path.
|
||||
// ─── WAV player ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// WAV header layout (canonical PCM, 44 bytes):
|
||||
// RIFF header : 12 bytes (RIFF, chunk size, WAVE)
|
||||
// fmt chunk : 24 bytes (fmt , 16, pcm=1, channels, rate, byte_rate, align, bits)
|
||||
// data chunk : 8 bytes (data, data_size)
|
||||
// PCM samples : data_size bytes
|
||||
|
||||
#define WAV_HDR_RIFF_OFS 0
|
||||
#define WAV_HDR_FMT_OFS 12
|
||||
#define WAV_HDR_DATA_OFS 36
|
||||
#define WAV_MIN_HEADER 44
|
||||
|
||||
typedef struct {
|
||||
uint16_t audio_format;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byte_rate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
uint32_t data_offset; // offset of PCM data in the buffer/file
|
||||
uint32_t data_size;
|
||||
} wav_info_t;
|
||||
|
||||
static esp_err_t parse_wav_header(const uint8_t *buf, size_t buf_len, wav_info_t *out)
|
||||
{
|
||||
if (buf_len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
|
||||
|
||||
// RIFF
|
||||
if (memcmp(buf, "RIFF", 4) != 0) return ESP_ERR_INVALID_ARG;
|
||||
if (memcmp(buf + 8, "WAVE", 4) != 0) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
// Walk chunks to find fmt and data (handles non-standard ordering / extra chunks)
|
||||
size_t pos = 12;
|
||||
bool got_fmt = false, got_data = false;
|
||||
|
||||
while (pos + 8 <= buf_len && !(got_fmt && got_data)) {
|
||||
uint32_t chunk_size;
|
||||
memcpy(&chunk_size, buf + pos + 4, 4);
|
||||
|
||||
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
|
||||
memcpy(&out->audio_format, buf + pos + 8, 2);
|
||||
memcpy(&out->channels, buf + pos + 10, 2);
|
||||
memcpy(&out->sample_rate, buf + pos + 12, 4);
|
||||
memcpy(&out->byte_rate, buf + pos + 16, 4);
|
||||
memcpy(&out->block_align, buf + pos + 20, 2);
|
||||
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
|
||||
got_fmt = true;
|
||||
} else if (memcmp(buf + pos, "data", 4) == 0) {
|
||||
out->data_offset = (uint32_t)(pos + 8);
|
||||
out->data_size = chunk_size;
|
||||
got_data = true;
|
||||
}
|
||||
pos += 8 + chunk_size;
|
||||
}
|
||||
|
||||
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
|
||||
if (out->audio_format != 1) {
|
||||
ESP_LOGW(TAG, "WAV format %d is not PCM (1) — unsupported", out->audio_format);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Stream raw PCM-16 samples to the speaker I2S channel.
|
||||
// Handles any sample rate (simple rate-adaptation via repeat/skip is NOT done here;
|
||||
// the test WAV and typical cues should be 16 kHz mono = native rate).
|
||||
static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
|
||||
bool loop, int max_loops)
|
||||
{
|
||||
if (!s_spk_handle_local) {
|
||||
ESP_LOGW(TAG, "play: no speaker handle — skip stream");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
int loops = 0;
|
||||
do {
|
||||
size_t offset = 0;
|
||||
while (offset < byte_len) {
|
||||
size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
|
||||
size_t written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle_local,
|
||||
pcm + offset, chunk,
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
loops++;
|
||||
} while (loop && (max_loops == 0 || loops < max_loops));
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ─── SD card mount (best-effort, guarded) ────────────────────────────────────
|
||||
|
||||
static bool s_sd_mounted = false;
|
||||
|
||||
static void ensure_sd_mounted(void)
|
||||
{
|
||||
if (s_sd_mounted) return;
|
||||
ESP_LOGI(TAG, "play: attempting SD card mount...");
|
||||
esp_err_t ret = bsp_sdcard_mount();
|
||||
if (ret == ESP_OK) {
|
||||
s_sd_mounted = true;
|
||||
ESP_LOGI(TAG, "play: SD card mounted at %s", BSP_SD_MOUNT_POINT);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "play: SD card mount failed: %s (will use fallback)", esp_err_to_name(ret));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── play task (off main task to allow loop without blocking CMD recv) ────────
|
||||
|
||||
typedef struct {
|
||||
char path[128];
|
||||
bool loop;
|
||||
} play_args_t;
|
||||
|
||||
static void play_task(void *arg)
|
||||
{
|
||||
play_args_t *pargs = (play_args_t *)arg;
|
||||
char path[128];
|
||||
bool loop = pargs->loop;
|
||||
strncpy(path, pargs->path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
free(pargs);
|
||||
|
||||
ESP_LOGI(TAG, "play_task: path=\"%s\" loop=%d", path, loop);
|
||||
|
||||
// Check for embedded asset path
|
||||
bool use_embedded = (strncmp(path, "embedded:", 9) == 0 ||
|
||||
strcmp(path, "<none>") == 0 ||
|
||||
strlen(path) == 0);
|
||||
|
||||
if (!use_embedded) {
|
||||
// Try SD card
|
||||
ensure_sd_mounted();
|
||||
if (!s_sd_mounted) {
|
||||
ESP_LOGW(TAG, "play: SD not mounted, falling back to embedded asset");
|
||||
use_embedded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!use_embedded) {
|
||||
// Build full path if not absolute
|
||||
char full_path[160];
|
||||
if (path[0] == '/') {
|
||||
snprintf(full_path, sizeof(full_path), "%s", path);
|
||||
} else {
|
||||
snprintf(full_path, sizeof(full_path), "%s/%s", BSP_SD_MOUNT_POINT, path);
|
||||
}
|
||||
|
||||
FILE *f = fopen(full_path, "rb");
|
||||
if (!f) {
|
||||
ESP_LOGW(TAG, "play: file not found: %s — falling back to embedded", full_path);
|
||||
use_embedded = true;
|
||||
} else {
|
||||
// Read WAV from file
|
||||
fseek(f, 0, SEEK_END);
|
||||
long fsize = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (fsize < WAV_MIN_HEADER || fsize > 512 * 1024) {
|
||||
ESP_LOGW(TAG, "play: file size %ld bytes — out of range, fallback", fsize);
|
||||
fclose(f);
|
||||
use_embedded = true;
|
||||
} else {
|
||||
uint8_t *fbuf = malloc((size_t)fsize);
|
||||
if (!fbuf) {
|
||||
ESP_LOGW(TAG, "play: alloc failed for %ld bytes — fallback", fsize);
|
||||
fclose(f);
|
||||
use_embedded = true;
|
||||
} else {
|
||||
size_t nread = fread(fbuf, 1, (size_t)fsize, f);
|
||||
fclose(f);
|
||||
|
||||
wav_info_t wi;
|
||||
esp_err_t ret = parse_wav_header(fbuf, nread, &wi);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "play: WAV parse error %s — fallback", esp_err_to_name(ret));
|
||||
free(fbuf);
|
||||
use_embedded = true;
|
||||
} else {
|
||||
ESP_LOGI(TAG, "play: WAV ok — %" PRIu32 " Hz %" PRIu16 "-bit %" PRIu16 " ch, %" PRIu32 " bytes PCM",
|
||||
wi.sample_rate, wi.bits_per_sample,
|
||||
wi.channels, wi.data_size);
|
||||
if (wi.bits_per_sample != 16) {
|
||||
ESP_LOGW(TAG, "play: %" PRIu16 "-bit WAV — only 16-bit supported, fallback",
|
||||
wi.bits_per_sample);
|
||||
free(fbuf);
|
||||
use_embedded = true;
|
||||
} else {
|
||||
// Real streaming from file
|
||||
stream_pcm16(fbuf + wi.data_offset, wi.data_size, loop, 3);
|
||||
free(fbuf);
|
||||
use_embedded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (use_embedded) {
|
||||
// Play embedded C5-E5-G5 cue (proves WAV decode + I2S path)
|
||||
wav_info_t wi;
|
||||
esp_err_t ret = parse_wav_header(s_embedded_wav, EMBEDDED_WAV_SIZE, &wi);
|
||||
if (ret == ESP_OK && wi.bits_per_sample == 16) {
|
||||
ESP_LOGI(TAG, "play: streaming embedded cue (%" PRIu32 " Hz %" PRIu16 "-bit, %" PRIu32 " bytes PCM)",
|
||||
wi.sample_rate, wi.bits_per_sample, wi.data_size);
|
||||
stream_pcm16(s_embedded_wav + wi.data_offset, wi.data_size, false, 1);
|
||||
} else {
|
||||
// Last-resort: beep
|
||||
ESP_LOGW(TAG, "play: embedded WAV parse error %s — beep fallback",
|
||||
esp_err_to_name(ret));
|
||||
audio_play_tone(880.0f, 200);
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
// ─── op=play ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t exec_play(const cJSON *a)
|
||||
{
|
||||
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
|
||||
const cJSON *loop_j = a ? cJSON_GetObjectItemCaseSensitive(a, "l") : NULL;
|
||||
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "<none>";
|
||||
int loop = cJSON_IsNumber(loop_j) ? (int) loop_j->valuedouble : 0;
|
||||
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://cue";
|
||||
bool loop = cJSON_IsNumber(loop_j) ? ((int)loop_j->valuedouble != 0) : false;
|
||||
|
||||
ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d (beep fallback — no media_manager on box3_voice)",
|
||||
path, loop);
|
||||
ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d", path, loop);
|
||||
|
||||
play_args_t *pargs = malloc(sizeof(play_args_t));
|
||||
if (!pargs) {
|
||||
ESP_LOGW(TAG, "play: alloc failed — beep fallback");
|
||||
audio_play_tone(880.0f, 200);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
strncpy(pargs->path, path, sizeof(pargs->path) - 1);
|
||||
pargs->path[sizeof(pargs->path) - 1] = '\0';
|
||||
pargs->loop = loop;
|
||||
|
||||
// Spawn a dedicated task (4 KB stack) so we don't block the CMD receive path.
|
||||
// Priority 4 = same as mic_capture.
|
||||
if (xTaskCreate(play_task, "cmd_play", 4096, pargs, 4, NULL) != pdPASS) {
|
||||
ESP_LOGW(TAG, "play: task create failed — beep fallback");
|
||||
free(pargs);
|
||||
audio_play_tone(880.0f, 200);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Acknowledgment beep: short 880 Hz tone (distinct from the 440 Hz boot tone).
|
||||
audio_play_tone(880.0f, 200);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// op=evt: {"n":"<name>"} — card→master direction; when arriving at the BOX-3
|
||||
// this is unusual (it means the master echoed an evt back). Log and ignore.
|
||||
// ─── op=evt ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t exec_evt(const cJSON *a)
|
||||
{
|
||||
const cJSON *name_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
|
||||
@@ -140,7 +531,8 @@ static esp_err_t exec_evt(const cJSON *a)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// op=led: {"p":"<pattern>"} — stub on BOX-3 (no dedicated LED ring in box3_voice).
|
||||
// ─── op=led ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t exec_led(const cJSON *a)
|
||||
{
|
||||
const cJSON *pat_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
|
||||
@@ -149,7 +541,7 @@ static esp_err_t exec_led(const cJSON *a)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ─── public entry point ──────────────────────────────────────────────────────
|
||||
// ─── public entry point ───────────────────────────────────────────────────────
|
||||
|
||||
esp_err_t cmd_exec_handle(const char *data, size_t len)
|
||||
{
|
||||
@@ -169,7 +561,7 @@ esp_err_t cmd_exec_handle(const char *data, size_t len)
|
||||
}
|
||||
|
||||
const char *op = op_j->valuestring;
|
||||
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a"); // args, may be NULL
|
||||
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a");
|
||||
|
||||
esp_err_t err;
|
||||
if (strcmp(op, "screen") == 0) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//
|
||||
// Supported ops:
|
||||
// screen {"t":"title","u":"subtitle","y":"symbol","e":"effect"} — display on LCD
|
||||
// play {"p":"<path>","l":0|1} — play media; falls back to beep + log if no file
|
||||
// effect: "pulse"|"glitch"|"gyro"|"none"
|
||||
// play {"p":"<path>","l":0|1} — stream WAV via I2S; embedded cue if no file
|
||||
// evt {"n":"<name>"} — log the event (future: relay to master)
|
||||
// led {"p":"<pattern>"} — log the pattern (stub)
|
||||
// <any> unknown op — log warn, ignore
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "driver/i2s_std.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -24,10 +26,14 @@ extern "C" {
|
||||
|
||||
// Parse and execute one CMD frame. `data` is the raw UTF-8 payload received via
|
||||
// scenario_mesh text callback (NUL-terminated). Returns ESP_OK if the op was
|
||||
// recognised and dispatched (even as a stub), ESP_ERR_INVALID_ARG on bad JSON
|
||||
// or missing `op`, ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
|
||||
// recognised and dispatched, ESP_ERR_INVALID_ARG on bad JSON or missing `op`,
|
||||
// ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
|
||||
esp_err_t cmd_exec_handle(const char *data, size_t len);
|
||||
|
||||
// Provide the speaker I2S channel handle so exec_play() can stream audio.
|
||||
// Must be called from main.c after speaker_init(), before any CMD arrives.
|
||||
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -422,6 +422,9 @@ void app_main(void)
|
||||
/* Initialize persistent speaker output (used for test tone + TTS playback) */
|
||||
speaker_init();
|
||||
|
||||
/* Pass speaker handle to CMD executor for real WAV playback */
|
||||
cmd_exec_set_spk_handle(s_spk_handle);
|
||||
|
||||
/* Play test tone to verify audio output */
|
||||
audio_test_tone();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user