feat(assets): embed LCD and audio resources in OTA app

This commit is contained in:
chenjintao
2026-10-08 16:58:33 +08:00
parent 3160a33f50
commit 7bc9a3dc76
43 changed files with 443 additions and 243 deletions
@@ -24,7 +24,7 @@
#endif
#ifndef CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE "local_wake_response.opus"
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE "audio_prompts/local_wake_response.opus"
#endif
#ifndef CONFIG_TQ_LOCAL_WAKE_RETRY_MS
@@ -55,7 +55,7 @@
* 2 秒则给播放器正常收尾机会,超过后才按停滞恢复,避免网络抖动造成频繁强停。
*/
#define CONTROL_PLANE_WATCHDOG_TASK_STACK_SIZE CONFIG_TQ_MULTIMODAL_WATCHDOG_TASK_STACK_BYTES
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE "/littlefs/audio_prompts/server_connect_success.opus"
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE "audio_prompts/server_connect_success.opus"
#define CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS 30000U
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS 1000U
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS 3U
@@ -42,14 +42,14 @@
* 多次确认用于排除负载突变导致的瞬时低压;紧急阈值只需两次以缩短保护延迟。
* 轻待机下采样间隔显著拉长,充电 GPIO 中断仍可及时唤醒状态更新。
*/
#define BATTERY_LOW_PROMPT_FILE "/littlefs/audio_prompts/battery_low_20.opus"
#define BATTERY_FULL_PROMPT_FILE "/littlefs/audio_prompts/charge_complete.opus"
#define BATTERY_LOW_PROMPT_FILE "audio_prompts/battery_low_20.opus"
#define BATTERY_FULL_PROMPT_FILE "audio_prompts/charge_complete.opus"
#ifndef CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE
#define CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE 0
#endif
#ifndef CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE
#define CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE "/littlefs/audio_prompts/shutdown_power_empty.opus"
#define CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE "audio_prompts/shutdown_power_empty.opus"
#endif
#if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE
@@ -55,7 +55,7 @@
#define CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES 12288
#endif
#define CONTROL_PLANE_WIFI_SUCCESS_PROMPT_FILE "/littlefs/audio_prompts/network_connect_success.opus"
#define CONTROL_PLANE_WIFI_SUCCESS_PROMPT_FILE "audio_prompts/network_connect_success.opus"
#define CONTROL_PLANE_STA_CONNECT_MAX_ATTEMPTS 3
#define CONTROL_PLANE_STA_RETRY_BASE_DELAY_MS 300
@@ -52,7 +52,7 @@
#endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE
#define CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE "standby_5min.opus"
#define CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE "audio_prompts/standby_5min.opus"
#endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT
@@ -25,15 +25,15 @@
/* 3 秒宽限过滤 WebSocket 短重连,5 秒冷却防止 Wi-Fi 与服务端事件交替造成提示音轰炸。 */
#define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/network_interrupted_repairing.opus"
"audio_prompts/network_interrupted_repairing.opus"
#define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/network_unstable.opus"
"audio_prompts/network_unstable.opus"
#define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/server_abnormal_interrupted.opus"
"audio_prompts/server_abnormal_interrupted.opus"
#define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/server_repair_wait_reopen.opus"
"audio_prompts/server_repair_wait_reopen.opus"
#define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \
"/littlefs/audio_prompts/signal_restored_business_resume.opus"
"audio_prompts/signal_restored_business_resume.opus"
typedef enum {
NETWORK_FEEDBACK_STATE_BOOTING = 0,
@@ -43,7 +43,7 @@
#define CONTROL_PLANE_PTT_HOLD_STEP_MS 80
/* 长按 PTT 以 80 ms 小步等待,既能及时感知释放,也能周期检查会话已失败或被取消。 */
#define CONTROL_PLANE_MUYU_PROMPT_FILE "muyu.opus"
#define CONTROL_PLANE_MUYU_PROMPT_FILE "audio_prompts/muyu.opus"
#ifndef CONFIG_TQ_LOCAL_TASK_CORE
#define CONFIG_TQ_LOCAL_TASK_CORE 1
-1
View File
@@ -336,7 +336,6 @@ esp_err_t domain_runtime_slot_feature_switch_init(void);
bool domain_runtime_slot_feature_switch_is_active(void);
esp_err_t domain_runtime_prepare_storage(void);
esp_err_t domain_runtime_mount_littlefs(void);
void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
@@ -905,11 +905,6 @@ esp_err_t domain_runtime_prepare_storage(void)
return platform_prepare_storage();
}
esp_err_t domain_runtime_mount_littlefs(void)
{
return platform_mount_littlefs();
}
void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
+14 -6
View File
@@ -16,6 +16,7 @@ set(platform_srcs
"src/platform_io_effects.c"
"src/lcd_direct.c"
"src/lcd_direct_assets.c"
"src/platform_asset_bundle.c"
"third_party/lodepng/lodepng.cpp"
"third_party/lodepng/lodepng_esp_alloc.cpp"
"third_party/lodepng/lodepng_esp_bridge.cpp"
@@ -54,7 +55,6 @@ set(platform_priv_requires
esp_wifi
esp_websocket_client
json
littlefs
log
espressif__mdns
mbedtls
@@ -90,6 +90,8 @@ set(platform_lcd_asset_manifest "${platform_lcd_asset_src_dir}/manifest.json")
set(platform_lcd_asset_optimizer "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/optimize_lcd_assets.py")
set(platform_asset_image_dir "${CMAKE_CURRENT_BINARY_DIR}/asset_image")
set(platform_asset_stage_stamp "${CMAKE_CURRENT_BINARY_DIR}/asset_image.stamp")
set(platform_asset_pack "${CMAKE_CURRENT_BINARY_DIR}/platform_assets.pack")
set(platform_asset_packer "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/pack_embedded_assets.py")
file(GLOB_RECURSE platform_audio_prompt_files CONFIGURE_DEPENDS
"${platform_audio_prompt_src_dir}/*.opus"
)
@@ -199,6 +201,7 @@ foreach(lcd_png_asset IN LISTS platform_lcd_png_assets)
)
endforeach()
# 先校验并暂存实际引用的 PNG/Opus,再由打包脚本生成应用内只读资源包。
add_custom_command(
OUTPUT "${platform_asset_stage_stamp}"
COMMAND ${PYTHON} "${platform_lcd_asset_optimizer}" check-current
@@ -214,9 +217,14 @@ add_custom_command(
"${platform_lcd_asset_optimizer}"
)
littlefs_create_partition_image(
gifpack
"${platform_asset_image_dir}"
FLASH_IN_PROJECT
DEPENDS "${platform_asset_stage_stamp}"
# 素材或打包脚本变化时重新生成包,并通过二进制链接目标纳入 OTA 应用镜像。
add_custom_command(
OUTPUT "${platform_asset_pack}"
COMMAND ${PYTHON} "${platform_asset_packer}"
"${platform_asset_image_dir}" "${platform_asset_pack}"
DEPENDS "${platform_asset_stage_stamp}" "${platform_asset_packer}"
VERBATIM
)
target_add_binary_data(${COMPONENT_LIB} "${platform_asset_pack}" BINARY
RENAME_TO platform_assets_pack)
-1
View File
@@ -1,6 +1,5 @@
dependencies:
idf: '>=4.4'
joltwallet/littlefs: 1.20.4
espressif/esp_websocket_client: ^1.6.1
espressif/esp_codec_dev: ^1.5.4
espressif/esp_audio_codec: ^2.4.1
-1
View File
@@ -221,7 +221,6 @@ typedef struct {
esp_err_t platform_init(void);
esp_err_t platform_prepare_storage(void);
bool platform_storage_is_ready(void);
esp_err_t platform_mount_littlefs(void);
void platform_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
@@ -7,7 +7,7 @@
extern "C" {
#endif
/* RGB565 图片描述符,实际像素由渲染任务根据路径加载和解码。 */
/* RGB565 图片描述符,实际像素由渲染任务根据逻辑名称查找和解码。 */
typedef struct {
const char *path;
} lcd_direct_image_t;
@@ -0,0 +1,14 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct {
const uint8_t *data;
size_t size;
} platform_asset_view_t;
/* 按包内完整相对名称查找素材,例如 lcd/ota1.png、audio_prompts/boot.opus;成功后 data 指向当前固件的只读映射区,无需释放。 */
esp_err_t platform_asset_find(const char *name, platform_asset_view_t *out_asset);
+2 -2
View File
@@ -7,8 +7,8 @@ extern "C" {
#endif
/*
* 同步播放 LittleFS 中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待,
* 返回时文件句柄已关闭;路径解析和播放超时均在实现内处理。
* 同步播放应用镜像中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待;
* 素材查找和播放超时均在实现内处理。
*/
esp_err_t voice_prompt_play_file(const char *path);
+10 -60
View File
@@ -7,15 +7,13 @@
#include "lcd_direct.h"
#include "lcd_direct_assets.h"
#include "platform_asset_bundle.h"
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "driver/gpio.h"
#include "driver/ledc.h"
@@ -55,7 +53,7 @@
#define LCD_DIRECT_PREHEAT_WAIT_TIMEOUT_MS 5000U
/*
* PNG 缓存固定6项并使用 LRU 淘汰,限制 PSRAM 峰值;边界测试恰好覆盖一次完整
* 缓存命中周期。路径上限同时约束缓存键和 LittleFS 资源路径复制。
* 缓存命中周期。名称上限同时约束缓存键和嵌入式素材索引。
*/
#define LCD_DIRECT_FRAME_CACHE_MAX 6U
#define LCD_DIRECT_BOUNDARY_TEST_CACHE_HIT_FRAMES LCD_DIRECT_FRAME_CACHE_MAX
@@ -316,55 +314,6 @@ static uint16_t lcd_direct_blend_rgb565(uint16_t dst, uint16_t src, uint8_t alph
return lcd_direct_rgb888_to_rgb565(out_r, out_g, out_b);
}
static esp_err_t lcd_direct_read_file_psram(const char *path, unsigned char **out_data, size_t *out_size)
{
/* 整个 PNG 文件优先读入 PSRAM;任何短读都视为失败并关闭句柄。 */
ESP_RETURN_ON_FALSE(path != NULL && out_data != NULL && out_size != NULL,
ESP_ERR_INVALID_ARG,
TAG,
"invalid PNG read request");
*out_data = NULL;
*out_size = 0;
errno = 0;
int fd = open(path, O_RDONLY);
if (fd < 0) {
ESP_LOGE(TAG, "open PNG asset failed path=%s errno=%d", path, errno);
return ESP_ERR_NOT_FOUND;
}
off_t file_size = lseek(fd, 0, SEEK_END);
if (file_size <= 0 || lseek(fd, 0, SEEK_SET) < 0) {
ESP_LOGE(TAG, "stat PNG asset failed path=%s errno=%d", path, errno);
close(fd);
return ESP_FAIL;
}
unsigned char *data = (unsigned char *)lcd_direct_alloc_psram((size_t)file_size);
if (data == NULL) {
ESP_LOGE(TAG, "allocate PNG file buffer failed path=%s size=%u", path, (unsigned)file_size);
close(fd);
return ESP_ERR_NO_MEM;
}
size_t offset = 0;
while (offset < (size_t)file_size) {
ssize_t bytes_read = read(fd, data + offset, (size_t)file_size - offset);
if (bytes_read <= 0) {
ESP_LOGE(TAG, "read PNG asset failed path=%s errno=%d", path, errno);
free(data);
close(fd);
return ESP_FAIL;
}
offset += (size_t)bytes_read;
}
close(fd);
*out_data = data;
*out_size = (size_t)file_size;
return ESP_OK;
}
static void lcd_direct_release_decoded_image(lcd_direct_decoded_image_t *decoded)
{
if (decoded == NULL) {
@@ -386,18 +335,19 @@ static esp_err_t lcd_direct_decode_png(const char *path,
"invalid PNG decode request");
memset(out_decoded, 0, sizeof(*out_decoded));
unsigned char *png_data = NULL;
size_t png_size = 0;
esp_err_t read_err = lcd_direct_read_file_psram(path, &png_data, &png_size);
if (read_err != ESP_OK) {
return read_err;
/* PNG 字节直接取自应用镜像的只读映射区;解码器只读取输入,不持有或释放该指针。 */
platform_asset_view_t asset = {0};
esp_err_t find_err = platform_asset_find(path, &asset);
if (find_err != ESP_OK) {
ESP_LOGE(TAG, "find PNG asset failed path=%s err=%s", path, esp_err_to_name(find_err));
return find_err;
}
unsigned char *rgba = NULL;
unsigned width = 0;
unsigned height = 0;
unsigned decode_err = lodepng_esp_decode32(&rgba, &width, &height, png_data, png_size);
free(png_data);
unsigned decode_err = lodepng_esp_decode32(&rgba, &width, &height,
asset.data, asset.size);
if (decode_err != 0U) {
ESP_LOGE(TAG, "decode PNG failed path=%s error=%u text=%s",
path,
+1 -1
View File
@@ -2,7 +2,7 @@
#include <string.h>
#define LCD_ASSET_BASE_PATH "/littlefs/lcd/"
#define LCD_ASSET_BASE_PATH "lcd/"
/*
* 静态表只保存资源元数据和路径,不在此文件执行 PNG 解码。
@@ -0,0 +1,76 @@
#include "platform_asset_bundle.h"
#include <stdbool.h>
#include <string.h>
#define ASSET_BUNDLE_HEADER_BYTES 12U
#define ASSET_ENTRY_HEADER_BYTES 6U
/* 素材包由构建脚本链接进应用镜像,链接符号界定只读 Flash 映射区。格式为 magic(8) + 数量(u32 LE),随后逐项存放名称长度(u16 LE)、数据长度(u32 LE)、UTF-8 名称和原始数据。 */
extern const uint8_t platform_assets_pack_start[]
asm("_binary_platform_assets_pack_start");
extern const uint8_t platform_assets_pack_end[]
asm("_binary_platform_assets_pack_end");
static uint16_t read_u16_le(const uint8_t *data)
{
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
static uint32_t read_u32_le(const uint8_t *data)
{
return (uint32_t)data[0] | ((uint32_t)data[1] << 8) |
((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24);
}
esp_err_t platform_asset_find(const char *name, platform_asset_view_t *out_asset)
{
if (name == NULL || out_asset == NULL || name[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
*out_asset = (platform_asset_view_t) {0};
/* 调用方必须使用包内完整相对名称,例如 audio_prompts/boot.opus。 */
const size_t name_len = strlen(name);
if (name_len == 0U) {
return ESP_ERR_INVALID_ARG;
}
const uint8_t *cursor = platform_assets_pack_start;
const uint8_t *end = platform_assets_pack_end;
if ((size_t)(end - cursor) < ASSET_BUNDLE_HEADER_BYTES ||
memcmp(cursor, "TQASSET1", 8U) != 0) {
return ESP_ERR_INVALID_RESPONSE;
}
const uint32_t count = read_u32_le(cursor + 8U);
cursor += ASSET_BUNDLE_HEADER_BYTES;
/* 每次推进游标前校验剩余字节,避免损坏的包头或条目越过链接区间。 */
for (uint32_t i = 0; i < count; ++i) {
if ((size_t)(end - cursor) < ASSET_ENTRY_HEADER_BYTES) {
return ESP_ERR_INVALID_RESPONSE;
}
const size_t stored_name_len = read_u16_le(cursor);
const size_t data_len = read_u32_le(cursor + 2U);
cursor += ASSET_ENTRY_HEADER_BYTES;
if (stored_name_len == 0U || data_len == 0U ||
stored_name_len > (size_t)(end - cursor)) {
return ESP_ERR_INVALID_RESPONSE;
}
const uint8_t *stored_name = cursor;
cursor += stored_name_len;
if (data_len > (size_t)(end - cursor)) {
return ESP_ERR_INVALID_RESPONSE;
}
const bool matches = stored_name_len == name_len &&
memcmp(stored_name, name, name_len) == 0;
if (matches) {
/* 直接返回镜像内数据视图,调用方只能读取,不能释放或写入。 */
out_asset->data = cursor;
out_asset->size = data_len;
return ESP_OK;
}
cursor += data_len;
}
return cursor == end ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_RESPONSE;
}
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
@@ -14,10 +13,10 @@
#include "esp_err.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_littlefs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "multimodal/audio_pipeline.h"
#include "platform_asset_bundle.h"
#include "power_manager.h"
#include "sdkconfig.h"
#include "voice_prompt.h"
@@ -30,12 +29,8 @@
#error "CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL must be defined in Kconfig"
#endif
/* 提示音从 gifpack LittleFS 分区读取。 */
#define VOICE_PROMPT_DEFAULT_BASE_PATH "/littlefs/audio_prompts"
#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs"
#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack"
/*
* 提示音文件由4字节小端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 提示音由4字节大端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 防止损坏文件触发异常分配。帧时长按60ms估算播放总时长,并额外留3秒用于队列
* 排空、codec 尾部播放和调度抖动。
*/
@@ -47,8 +42,6 @@
static const char *TAG = "power_manager";
static const char *VOICE_TAG = "voice_prompt";
static bool s_power_hold_configured;
static bool s_voice_prompt_littlefs_mount_attempted;
static esp_err_t s_voice_prompt_littlefs_mount_result = ESP_ERR_INVALID_STATE;
/* GPIO 掩码集中构造,负数配置代表该板型不使用电源保持引脚。 */
static uint64_t power_manager_gpio_bit_mask(int gpio_num)
@@ -120,90 +113,25 @@ void platform_power_hold_init(void)
power_manager_set_keep_awake(true);
}
esp_err_t platform_mount_littlefs(void)
{
/* 记录首次挂载结果,使重复调用幂等且不会重复注册同一 VFS 路径。 */
if (s_voice_prompt_littlefs_mount_attempted) {
return s_voice_prompt_littlefs_mount_result;
}
esp_vfs_littlefs_conf_t conf = {
.base_path = VOICE_PROMPT_LITTLEFS_BASE_PATH,
.partition_label = VOICE_PROMPT_LITTLEFS_PARTITION,
.partition = NULL,
.format_if_mount_failed = false,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err == ESP_ERR_INVALID_STATE &&
esp_littlefs_mounted(VOICE_PROMPT_LITTLEFS_PARTITION)) {
err = ESP_OK;
}
if (err == ESP_OK) {
size_t total_bytes = 0;
size_t used_bytes = 0;
esp_err_t info_err = esp_littlefs_info(VOICE_PROMPT_LITTLEFS_PARTITION,
&total_bytes,
&used_bytes);
if (info_err == ESP_OK) {
ESP_LOGI(VOICE_TAG,
"LittleFS mounted at %s, used=%u total=%u",
VOICE_PROMPT_LITTLEFS_BASE_PATH,
(unsigned)used_bytes,
(unsigned)total_bytes);
}
} else {
ESP_LOGW(VOICE_TAG,
"Mount LittleFS failed: %s",
esp_err_to_name(err));
}
s_voice_prompt_littlefs_mount_attempted = true;
s_voice_prompt_littlefs_mount_result = err;
return err;
}
esp_err_t voice_prompt_play_file(const char *path)
{
/* 文件采用“长度头 + Opus 数据”帧格式,逐帧送入统一的下行播放管线。 */
/* 素材位于应用镜像的只读区,逐帧送入统一的下行播放管线。 */
if (path == NULL || path[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
esp_err_t mount_err = platform_mount_littlefs();
if (mount_err != ESP_OK) {
return mount_err;
}
char resolved_path[160] = {0};
if (path[0] == '/') {
int written = snprintf(resolved_path, sizeof(resolved_path), "%s", path);
if (written < 0 || written >= (int)sizeof(resolved_path)) {
return ESP_ERR_INVALID_ARG;
}
} else {
int written = snprintf(resolved_path,
sizeof(resolved_path),
"%s/%s",
VOICE_PROMPT_DEFAULT_BASE_PATH,
path);
if (written < 0 || written >= (int)sizeof(resolved_path)) {
return ESP_ERR_INVALID_ARG;
}
}
FILE *file = fopen(resolved_path, "rb");
if (file == NULL) {
ESP_LOGW(VOICE_TAG, "Open prompt failed: %s", resolved_path);
return ESP_ERR_NOT_FOUND;
/* 查找结果是固件内的只读视图,播放期间无需挂载文件系统或管理文件句柄。 */
platform_asset_view_t asset = {0};
esp_err_t err = platform_asset_find(path, &asset);
if (err != ESP_OK) {
ESP_LOGW(VOICE_TAG, "Find prompt failed: %s (%s)", path, esp_err_to_name(err));
return err;
}
uint32_t playback_generation = 0U;
esp_err_t err = multimodal_audio_begin_downstream_response(&playback_generation);
err = multimodal_audio_begin_downstream_response(&playback_generation);
if (err != ESP_OK) {
ESP_LOGW(VOICE_TAG, "Begin prompt playback failed: %s", esp_err_to_name(err));
fclose(file);
return err;
}
@@ -213,25 +141,23 @@ esp_err_t voice_prompt_play_file(const char *path)
ESP_LOGW(VOICE_TAG,
"Allocate prompt opus packet buffer in PSRAM failed: %u bytes",
(unsigned)VOICE_PROMPT_MAX_OPUS_PACKET_BYTES);
fclose(file);
(void)multimodal_audio_finish_downstream_response();
return ESP_ERR_NO_MEM;
}
uint32_t packet_count = 0;
size_t total_bytes = 0;
size_t offset = 0;
while (true) {
uint8_t header[VOICE_PROMPT_FRAME_HEADER_BYTES] = {0};
size_t header_read = fread(header, 1, sizeof(header), file);
if (header_read == 0U) {
break;
}
if (header_read != sizeof(header)) {
ESP_LOGW(VOICE_TAG, "Truncated prompt frame header: %s", resolved_path);
/* 原 Opus 文件仍按 4 字节大端帧长逐帧存放;先校验边界,再拷入播放缓冲区。 */
while (offset < asset.size) {
if (asset.size - offset < VOICE_PROMPT_FRAME_HEADER_BYTES) {
ESP_LOGW(VOICE_TAG, "Truncated prompt frame header: %s", path);
err = ESP_ERR_INVALID_SIZE;
break;
}
const uint8_t *header = asset.data + offset;
offset += VOICE_PROMPT_FRAME_HEADER_BYTES;
uint32_t packet_len = ((uint32_t)header[0] << 24) |
((uint32_t)header[1] << 16) |
@@ -241,17 +167,18 @@ esp_err_t voice_prompt_play_file(const char *path)
ESP_LOGW(VOICE_TAG,
"Invalid prompt opus packet size: %u in %s",
(unsigned)packet_len,
resolved_path);
path);
err = ESP_ERR_INVALID_SIZE;
break;
}
size_t payload_read = fread(packet, 1, packet_len, file);
if (payload_read != packet_len) {
ESP_LOGW(VOICE_TAG, "Truncated prompt opus packet: %s", resolved_path);
if (packet_len > asset.size - offset) {
ESP_LOGW(VOICE_TAG, "Truncated prompt opus packet: %s", path);
err = ESP_ERR_INVALID_SIZE;
break;
}
memcpy(packet, asset.data + offset, packet_len);
offset += packet_len;
err = multimodal_audio_handle_downstream_opus(packet, packet_len);
if (err != ESP_OK) {
@@ -265,7 +192,6 @@ esp_err_t voice_prompt_play_file(const char *path)
}
free(packet);
fclose(file);
esp_err_t finish_err = multimodal_audio_finish_downstream_response();
if (err == ESP_OK) {
err = finish_err;
@@ -286,7 +212,7 @@ esp_err_t voice_prompt_play_file(const char *path)
if (err == ESP_OK) {
ESP_LOGI(VOICE_TAG,
"Prompt completed: %s packets=%u bytes=%u",
resolved_path,
path,
(unsigned)packet_count,
(unsigned)total_bytes);
}