【Funpack2-5】基于ESP32-S3-BOX-Lite的语音合成与播报系统
Github: EmbeddedCamerata/esp-box-lite-bfans-tts
本项目基于ESP32-S3-BOX-Lite,使用esp-idf开发,连接WiFi并发出HTTPS请求,返回B站用户数据信息,再使用cJSON完成json数据解析,得到用户粉丝数,最后通过TTS实现语音合成与播报。
Github: espressif/esp-box
Github: espressif/esp-idf
ESP32-S3-BOX-Lite 是目前对应的 AIoT 应用开发板,搭载支持 AI 加速的 ESP32-S3 Wi-Fi + Bluetooth 5 (LE) SoC。该开发板配备一块2.4寸LCD显示屏、双麦克风、一个扬声器、两个用于硬件拓展的Pmod™兼容接口、结合三个独立按键,可构建多样的HMI人机交互应用。
ESP32-S3 是一款集成 2.4 GHz Wi-Fi 和 Bluetooth 5 (LE) 的 MCU 芯片,支持远距离模式 (Long Range)。ESP32-S3 搭载 Xtensa® 32 位 LX7 双核处理器,主频高达 240 MHz,内置 512 KB SRAM (TCM),具有 45 个可编程 GPIO 管脚和丰富的通信接口。ESP32-S3 支持更大容量的高速 Octal SPI flash 和片外 RAM,支持用户配置数据缓存与指令缓存。
强烈建议采用esp-box仓库中所述的稳定版本:esp-idf v4.4.4
以及 esp-box v0.3.0
。二者环境的安装及基本例程上手参见各说明文档,在此不作赘述。
工程目录上,可仿照乐鑫提供的 模板 构建自己的工程目录。例如:
├ build(编译时所产生的构建文件)
├ main
│ ├ include
│ │ ├ wifi_connect.h
│ │ └ ...(其余头文件)
│ └ src
│ │ ├ wifi_connect.c
│ │ └ ...(其余源代码)
│ ├ main.c
│ ├ CMakeLists.txt
│ └ component.mk
├ resources
│ └ server_root_cert.pem(HTTPS请求所用到的网站CA root cert)
├ CMakeLists.txt
├ Makefile
├ partitions.csv
└ ...
⚠️ 注意:工程根目录下 CMakeLists.txt
中,EXTRA_COMPONENT_DIRS
需要指定本地esp-box仓库所在路径,例如:
cmake_minimum_required(VERSION 3.5)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS ../esp-box/components)
project(esp-box-lite-bfans-tts)
参考esp-box中的示例,需要注意的是由于会用到TTS,调用 bsp_board_power_ctrl()
打开AUDIO电源是必要的。
ESP_ERROR_CHECK(bsp_board_init());
ESP_ERROR_CHECK(bsp_board_power_ctrl(POWER_MODULE_AUDIO, true));
参考esp-idf所提供的有关wifi的示例:station_example_main,为wifi功能单独设置 .c/.h
文件实现。将示例中的宏定义修改为实际值:
#define EXAMPLE_ESP_WIFI_SSID YOUR_WIFI_SSID
#define EXAMPLE_ESP_WIFI_PASS YOUR_WIFI_PASSWORD
#define EXAMPLE_ESP_MAXIMUM_RETRY 3
在工程的主程序 main.c
中,直接使用示例的主程序即可:
/* Initialize NVS */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
wifi_init_sta();
⚠️ 注意:在初始化wifi协议栈之前,需初始化NVS(非易失性存储单元)。由于乐鑫会将SSID与密码存储在NVS中,因此需要对开发板做分区配置 partitions.csv
,根据esp-box中的例程,增加一个nvs分区与voice_data分区(TTS会用到):
# Name | Type | SubType | Offset | Size |
---|---|---|---|---|
nvs | data | nvs | 0x9000 | 0x4000 |
factory | app | factory | 0x010000 | 6M |
voice_data | data | fat | 0x610000 | 3890K |
参考 esp-idf文档:分区表
该部分完成对 api.bilibili.com 发出HTTPS请求,URL为:https://api.bilibili.com/x/relation/stat?vmid=
+ user_uid
。
参考esp-idf所提供的有关HTTPS请求的示例:https_request_example_main,为该功能单独设置 .c/.h
文件实现。将示例中的宏定义修改为实际值:
#define WEB_SERVER "api.bilibili.com"
#define WEB_PORT "443"
#define WEB_URL "https://api.bilibili.com/x/relation/stat?vmid=user_uid" // 用户B站UID
在主程序中,由于WiFi功能的初始化已经为我们完成了NVS、netif、event_loop_create等初始化操作,因此只需要示例主程序中创建线程的操作即可:
xTaskCreate(&https_request_task, "https_get_task", 8192, NULL, 5, NULL);
⚠️ 注意:HTTPS请求,需要网站的CA根证书文件 .pem
。在示例中有注释指导如何生成网站的 .pem
文件:
/* Root cert for howsmyssl.com, taken from server_root_cert.pem
The PEM file was extracted from the output of this command:
openssl s_client -showcerts -connect www.howsmyssl.com:443
生成 .pem
文件后,修改工程目录下的 component.mk
,将 .pem
文件以二进制形式储存(笔者将其单独放在工程根目录下 .resources
目录下):
COMPONENT_EMBED_TXTFILES := ../resources/server_root_cert.pem
所返回的请求数据(字符串)形如:
HTTP/1.1 200 OK
Date: Sun, 16 Jul 2023 15:33:55 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 108
Connection: keep-alive
Bili-Status-Code: 0
Bili-Trace-Id: 550bd14e7a64b40d
X-Bili-Trace-Id: 26617a088e651658550bd14e7a64b40d
Expires: Sun, 16 Jul 2023 15:33:54 GMT
Cache-Control: no-cache
X-Cache-Webcdn: BYPASS from blzone04
{"code":0,"message":"0","ttl":1,"data":{"mid":42602419,"following":243,"whisper":0,"black":0,"follower":69}}
其中json字段的 data
中 follower
是想解析得到的数据。由于TTS将会用到返回的数据,因此在每次获取到返回数据后,用 memcpy
将其保存至全局变量 https_req_buf
。
#define MAX_REQUEST_BUF_LEN 512
char https_req_buf[MAX_REQUEST_BUF_LEN];
static void https_get_request(esp_tls_cfg_t cfg, const char *WEB_SERVER_URL, const char *REQUEST)
{
...
/* Print response directly to stdout as it is read */
for (int i = 0; i < len; i++)
{
putchar(buf[i]);
}
putchar('\n'); // JSON output doesn't have a newline at end
memcpy(https_req_buf, buf, MAX_REQUEST_BUF_LEN); // 保存至全局变量内
} while (1);
...
}
esp-idf/components下,已经包含 cJSON 库,用于做json数据的解析或合成。在上一节已经知道了API返回的数据,因此先简单做个字符串截断 strchr(buf, '{')
,待获取到json数据后,再提取json中 follower
字段的内容。
esp_err_t json_parse_followers(char **parsed)
{
char *json_buf;
cJSON *cjson_root = NULL;
json_buf = strchr(https_req_buf, '{');
cjson_root = cJSON_Parse(json_buf);
cJSON *cjson_data = cJSON_GetObjectItem(cjson_root, "data");
cJSON *cjson_follower = cJSON_GetObjectItem(cjson_data, "follower");
*parsed = (char*)malloc(32*sizeof(char));
*parsed = cJSON_Print(cjson_follower);
cJSON_Delete(cjson_root);
return ESP_OK;
}
// 外部调用方式
char *followers;
esp_err_t err = json_parse_followers(&followers);
⚠️ 在此输入参数类型为 char **
;使用cJSON的最后,调用 cJSON_Delete()
释放指针。
经过json解析后得到的粉丝数数据,交由TTS模块完成语音合成。首先需要为板子进行TTS功能的初始化。
参考esp-skainet所提供的有关TTS的示例:chinese_tts
esp_tts_voice_data_xiaole.dat
),参考chinese_tts/README,工程源码目录下 ./main/CMakeLists.txt
需要添加如下内容:set(voice_data_image ${PROJECT_DIR}/../esp-box/components/esp-sr/esp-tts/esp_tts_chinese/esp_tts_voice_data_xiaole.dat)
add_custom_target(voice_data ALL DEPENDS ${voice_data_image})
add_dependencies(flash voice_data)
partition_table_get_partition_info(size "--partition-name voice_data" "size")
partition_table_get_partition_info(offset "--partition-name voice_data" "offset")
if("${size}" AND "${offset}")
esptool_py_flash_to_partition(flash "voice_data" "${voice_data_image}")
else()
set(message "Failed to find model in partition table file"
"Please add a line(Name=voice_data, Type=data, Size=3890K) to the partition file.")
endif()
⚠️ 注意:修改 voice_data_image
为实际对应 esp_tts_voice_data_xiaole.dat
路径,其余可照抄。
static const char *TAG = "TTS report";
static esp_tts_handle_t *tts_handle;
esp_err_t tts_init()
{
/* 1. create esp tts handle */
// initial voice set from separate voice data partition
const esp_partition_t *part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "voice_data");
if (part == NULL)
{
ESP_LOGE(TAG, "Couldn't find voice data partition!\n");
return ESP_FAIL;
}
else
{
ESP_LOGI(TAG, "voice_data paration size:%d\n", part->size);
}
void *voicedata;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
esp_partition_mmap_handle_t mmap;
esp_err_t err = esp_partition_mmap(part, 0, part->size, ESP_PARTITION_MMAP_DATA, &voicedata, &mmap);
#else
spi_flash_mmap_handle_t mmap;
esp_err_t err = esp_partition_mmap(part, 0, part->size, SPI_FLASH_MMAP_DATA, &voicedata, &mmap);
#endif
if (err != ESP_OK)
{
ESP_LOGE(TAG, "Couldn't map voice data partition!\n");
return ESP_FAIL;
}
esp_tts_voice_t *voice = esp_tts_voice_set_init(&esp_tts_voice_xiaole, (int16_t *)voicedata);
tts_handle = esp_tts_create(voice);
if (!tts_handle)
{
ESP_LOGE(TAG, "Created tts_handle failed!\n");
return ESP_FAIL;
}
return ESP_OK;
}
⚠️ 注意:使用的是xiaole语音数据而非template,esp_tts_voice_t *voice = esp_tts_voice_set_init(&esp_tts_voice_xiaole, (int16_t *)voicedata)
参考esp-sr/speech_command_recognition所提供的简单的语音合成示例:
esp_err_t tts_report(char *prompt, unsigned int speed)
{
if (esp_tts_parse_chinese(tts_handle, prompt))
{
int len[1] = {0};
size_t bytes_write = 0;
do
{
short *pcm_data = esp_tts_stream_play(tts_handle, len, speed);
i2s_write(I2S_NUM_0, pcm_data, len[0] * 2, &bytes_write, portMAX_DELAY);
} while (len[0] > 0);
}
else
{
ESP_LOGE(TAG, "Parse %s failed!\n", prompt);
return ESP_FAIL;
}
esp_tts_stream_reset(tts_handle);
return ESP_OK;
}
⚠️ 注意:调用 i2s_write()
而非 esp_audio_play()
完成数据写入外设。
其次,还需要调节语音播报语速。即便 esp_tts_stream_play()
函数输入 speed=0
,其语音播报速度依旧太快,需要在开发板BSP相关代码做修改。具体修改 components/bsp/src/boards/esp32_s3_box_lite.c 中139~140行,将I2S与语音编解码时钟频率降低。尝试取11K时语速合适。
// ESP_ERROR_CHECK(bsp_i2s_init(I2S_NUM_0, 16000));
// ESP_ERROR_CHECK(bsp_codec_init(AUDIO_HAL_16K_SAMPLES));
ESP_ERROR_CHECK(bsp_i2s_init(I2S_NUM_0, 11000));
ESP_ERROR_CHECK(bsp_codec_init(AUDIO_HAL_11K_SAMPLES));
将TTS语音合成与播报的功能绑定在按键回调上。ESP32-S3-BOX-Lite正面三个按钮从左至右分别为 BOARD_BTN_ID_PREV
、BOARD_BTN_ID_ENTER
、BOARD_BTN_ID_NEXT
。参考esp-box出厂程序中按键的实现,基本的按键回调函数绑定为:
bsp_btn_register_callback(BOARD_BTN_ID_PREV, BUTTON_PRESS_DOWN, tts_report_cb, NULL);
含义为按下正面左侧按钮时,触发 tts_report_cb
函数。该函数完成对HTTPS请求返回数据的json格式解析、将得到的粉丝数(字符串)显示在屏幕上、将粉丝数字符串转换为字符串,例如,“粉丝数一百一十四”、“粉丝数一千九百一十九”、最后调用 tts_report()
完成语音播报。
void tts_report_cb(void *arg)
{
char *followers;
char prompt[64];
/* Parse the followers number(string) */
esp_err_t err = json_parse_followers(&followers);
/* Update the followers number on the screen */
lvgl_display_update(atoi(followers));
/* Convert the string to Chinese prompt */
err = followers_to_prompt(followers, prompt);
/* Play the prompt at speed=1 */
err = tts_report(prompt, 1);
}
esp-box内已经实现LVGL的移植,可参考出厂程序示例学习LVGL用法。首先,需要在主程序中初始化LVGL:
ESP_ERROR_CHECK(lv_port_init());
bsp_lcd_set_backlight(true);
之后简单地在屏幕中央打印出“Followers: 102”即可,先初始化显示“Followers: 0”:
void lvgl_display_init()
{
/* Change the active screen's background color */
lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0x003a57), LV_PART_MAIN);
/* Create a white label, set its text and align it to the center */
label = lv_label_create(lv_scr_act());
lv_obj_set_style_text_font(label, &lv_font_montserrat_24, 0); // Font size 24
lv_label_set_text(label, "Followers: 0");
lv_obj_set_style_text_color(lv_scr_act(), lv_palette_main(LV_PALETTE_ORANGE), LV_PART_MAIN);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
}
后在按键回调中, lvgl_display_update()
函数内刷新显示的粉丝数:
void lvgl_display_update(int num)
{
lv_label_set_text_fmt(label, "Followers: %d", num);
}
并且,在工程主程序的最后:
do
{
lv_task_handler();
} while (vTaskDelay(1), true);
通过 idf.py flash monitor
完成程序下载与监控。初始屏幕显示:
之后,开发板将连接上wifi,发出HTTPS请求并返回如下图所示数据:
之后,按下正面左键,开发板将合成语音并播报,如下图所示,“粉丝数六十九”:
详细展示参见:B站:基于ESP32-S3-BOX-Lite的语音合成与播报系统
本次工程基于 esp-idf 开发,实现了 WiFi 连接、HTTPS请求,实现B站用户粉丝数的读取,并通过 cJSON 完成 json 数据的解析,最后通过 TTS 实现语音合成与播报。
esp-idf 开发框架旨在基础地、详尽地操控整个芯片与板级的外设与配置。基于 esp-idf 的工程构建,使用 CMake 配置工程与资源的方式非常优雅,在 Linux 上编译速度快(因为Windows 上编译的速度更慢)。esp-idf 提供的各种外设示例也都能参考。TTS 这边,一开始是想参考 esp-skainet 那边基于 esp32-s3-box 的示例,但好像有关音频 codec 的移植似乎有些混乱。期望 esp-skainet 那边能官方支持 esp32-s3-box-lite。