[Funpack2-5]基于ESP32-S3的一个语音播报系统
Funpack活动项目,基于ESP32-Box-lite实现的语音交互小盒子。使用ESP32的WiFi和TTS功能,实现一个语音播报系统,如联网获取粉丝数并播报或者获取天气并播报。
项目背景
这是我在参与的第一个Funpack活动项目。任务内容如下:
任务一:
- 使用ESP32的WiFi和TTS功能,实现一个语音播报系统,如联网获取粉丝数并播报或者获取天气并播报
任务二:
- 使用ESP32的声学前端算法,实现一个短时录音并处理后回放,如按下按键录制5秒音频,进行降噪和增益处理后输出
任务三:
- 使用板卡的屏幕和联网功能,实现一个在线电子书浏览器,从网络上获取文本并显示在屏幕上,通过按键翻页
任务四:
- 若您针对这个板卡有更好的创意,可自命题完成(难度不能低于以上任务)
这次活动我选择完成的是任务一,实现一个语音播报系统,希望通过本次活动学习到关于语音识别和语音合成(TTS)相关技术。
硬件介绍
ESP32-S3-BOX-LITE
ESP-BOX 是乐鑫发布的新一代 AIoT 开发平台,ESP32-S3-BOX-Lite 开发套件配备了一块 2.4 寸 LCD 显示屏、双麦克风、一个扬声器、两个用于硬件拓展的 Pmod™ 兼容接口和3个独立按键,可构建多样的 HMI 人机交互应用。开发板可实现离线语音唤醒和命令词识别,支持乐鑫自研的高性能声学前端算法构建语音交互系统。开发者可利用开源的 SDK轻松构建在线离线语音助手、智能语音设备、HMI 人机交互设备、多协议网关等多样的应用。
特性:
- 双麦克风支持远场语音交互
- 高唤醒率的离线语音唤醒
- 高识别率的离线中英文命令词识别
- 可重新配置的200+中文和英文语音命令
- 可连续识别和唤醒中断
- 灵活且可重用的 GUI 框架
- 端到端一站式接入云平台AIoT开发框架ESP-RainMaker
- Pmod™兼容接口支持扩展外设模块
- 提供了大量的使用说明和开发案例
项目介绍
这次的项目我命名为ESPandora,灵感来源于潘多拉魔盒,小小的盒子蕴含了无尽的可能。在项目中我主要使用了语音识别库(SR)实现了通过语音查询当前天气和B站粉丝数量的并进行语音播报(TTS)的一个功能。同时为了也制作了一个简单的阅读本地(SPIFFS)书籍的功能。
项目基于ESP-IDF进行开发,使用FreeRTOS进行多任务管理,使用了LVGL进行图形用户界面绘制。
程序编码
一、搭建环境
用惯了JetBrains家的产品(主要是 花了钱买的),而且看了一下Clion官方也有对ESP-IDF的支持文档,就干脆用了Clion。实际开发体验很不错。
安装ESP-IDF:
参考文档:https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/get-started/linux-macos-setup.html
这里建议安装的时候使用乐鑫自己的源,不然由于众所周知的原因,直接从GitHub上去下载的话慢不慢还是一说,可能都无法正常安装。
使用乐鑫源:
export IDF_GITHUB_ASSETS="dl.espressif.com/github_assets"
配置Clion:
参考文档:https://www.jetbrains.com/help/clion/esp-idf.html
表 1关键要配置好环境变量
二、程序设计与实现
软件流程图
工程目录结构
语音识别与语音合成
该项目使用了乐鑫 提供的 ESP-SR进行语音识别,使用ESP-TTS进行语音合成以完成语音播报功能。
乐鑫 TTS 语音合成模型是一个为嵌入式系统设计的轻量化语音合成系统,具有如下主要特性:
- 目前 仅支持中文
- 输入文本采用 UTF-8 编码
- 输出格式采用流输出,可减少延时
- 多音词发音自动识别
- 可调节合成语速
- 数字播报优化
- 自定义声音集(敬请期待)
乐鑫 TTS 的当前版本基于拼接法,主要组成部分包括:
解析器 (Parser):根据字典与语法规则,将输入文本(采用 UTF-8 编码)转换为拼音列表。
合成器 (Synthesizer):根据解析器输出的拼音列表,结合预定义的声音集,合成波形文件。默认输出格式为:单声道,16 bit @ 16000Hz。
系统框图如下:
通过idf.py menuconfig我们可以配置ESP-SR选择唤醒词。
通过上图菜单中的Select Wake words即可在已内置的唤醒词中进行选择。
配置好唤醒词之后,我们还需要在对TTS进行配置。
首先是选择语音文件,语音文件可以在managed_components/espressif__esp-sr/esp-tts/esp_tts_chinese下找到,我这里用的是esp_tts_voice_data_xiaoxin_small.dat,为了方便将其拷贝到了项目根目录。
然后要调整分区表,增加语音数据分区,同时因为总共只有16M的flash,其他分区可能也需要酌情进行调整,具体可以参考我的分区配置。
# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
# Name, Type, SubType, Offset, Size, Flags
sec_cert, data, , 0xd000, 0x3000,
nvs, data, nvs, 0x10000, 0x6000,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
fctry, data, nvs, , 0x6000,
ota_0, app, ota_0, , 6M,
# ota_1, app, ota_1, , 2700K,
storage, data, spiffs, , 1M,
model, data, spiffs, , 4847K,
voice_data, data, fat, , 3M
配置好分区表后还要在app/CMakeLists.txt 增加以下代码,使语音文件可以在烧录使自动写入到语音数据分区。
set(voice_data_image ${PROJECT_DIR}/esp_tts_voice_data_xiaoxin_small.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()
因为不用每次都写入,这个步骤也可以自己手动完成,具体操作方法可以参考ESP-TTS的官方文档。
参考文档:
- ESP-SR:https://docs.espressif.com/projects/esp-sr/zh_CN/latest/esp32s3/getting_started/readme.html
- ESP-TTS:https://docs.espressif.com/projects/esp-sr/zh_CN/latest/esp32s3/speech_synthesis/readme.html
完成以上工作之后我们就可以开始进行语音识别的编码部分了。
首先要添加两个语音指令用于查询B站粉丝数和天气信息,
在main/app/app_sr.h中向枚举sr_user_cmd_t中添加以下两个成员,注意应添加到SR_CMD_MAX之前:
SR_CMD_FENSI,
SR_CMD_WEATHER,
再在main/app/app_sr.c中找到数组变量g_default_cmd_info增加以下两个成员:
{SR_CMD_FENSI, SR_LANG_CN, 0, "B站粉丝", "fen si", {NULL}},
{SR_CMD_WEATHER, SR_LANG_CN, 0, "天气", "tian qi", {NULL}},
这里的关键是后面的"fen si"和"tian qi",这个是语音指令的拼音。至此ESP-SR已经能对语音指令进行识别,只是还不能响应相应动作。
下面还要进行语音指令相应部分代码的编写。
打开文件main/app/app_sr_handler.c,找到函数void sr_handler_task(void *pvParam),在其中的switch (cmd->cmd) {部分增加以下代码:
case SR_CMD_FENSI:
ESP_LOGW(TAG, "SR FENSI!!!!");
play_bilibili_fans();
ESP_LOGW(TAG, "SR FENSI --- END!!!!");
break;
case SR_CMD_WEATHER:
ESP_LOGW(TAG, "SR WEATHER!!!!");
play_weather();
ESP_LOGW(TAG, "SR WEATHER --- END!!!!");
break;
下面是play_bilibili_fans()和play_weather()的两个函数以及语音播报函数tts_read()的实现:
// 语音播报函数
void tts_read(char *str)
{
/*** 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;
} 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_tts_voice_t *voice=esp_tts_voice_set_init(&esp_tts_voice_template, (int16_t*)voicedata);
esp_tts_handle_t *tts_handle=esp_tts_create(voice);
/*** 2. play prompt text ***/
ESP_LOGI(TAG, "play prompt text: %s", str);
if (esp_tts_parse_chinese(tts_handle, str)) {
int len[1]={0};
bsp_codec_config_t *codec_handle = bsp_board_get_codec_handle();
codec_handle->i2s_reconfig_clk_fn(16000, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO);
codec_handle->mute_set_fn(false);
codec_handle->volume_set_fn(100, NULL);
do {
short *pcm_data=esp_tts_stream_play(tts_handle, len, 3);
size_t bytes_written = 0;
codec_handle->i2s_write_fn(pcm_data, len[0]*2, &bytes_written, portMAX_DELAY);
} while(len[0]>0);
vTaskDelay(pdMS_TO_TICKS(20));
}
esp_tts_stream_reset(tts_handle);
}
int play_bilibili_fans()
{
sr_anim_set_text("正在请求...");
char *prompt1 = (char*)malloc(50);
char *prompt2 = (char*)malloc(50);
int fans = http_get_bilibili_fans();
if (fans < 0) {
strcpy(prompt1, "获取粉丝数失败");
} else {
char *fansCn = (char*) malloc(30);
memset(fansCn, 0, sizeof(fansCn));
num2cn(fans, fansCn);
sprintf(prompt1, "必站粉丝%s人", fansCn);
sprintf(prompt2, "B站粉丝%d人", fans);
sr_anim_set_text(prompt2);
free(fansCn);
}
tts_read(prompt1);
free(prompt1);
free(prompt2);
}
int play_weather()
{
weather_result_t *weather = malloc(sizeof(weather_result_t));
memset(weather, 0, sizeof(weather_result_t));
char *str = malloc(128);
memset(str, 0, 128);
sr_anim_set_text("正在请求...");
esp_err_t ret = http_get_weather(weather);
ESP_LOGI(TAG, "play_weather ret=%d", ret);
if (ret != ESP_OK) {
strcpy(str, "获取天气失败");
sr_anim_set_text(str);
tts_read(str);
} else {
char tempCn[12];
char windSpeedCn[12];
char humiCn[12];
memset(tempCn, 0, 12);
memset(windSpeedCn, 0, 12);
memset(humiCn, 0, 12);
num2cn(atoi(weather->temp), &tempCn);
num2cn(atoi(weather->humi), &humiCn);
num2cn(atoi(weather->windSpeed), &windSpeedCn);
sprintf(str, "%s %s %s℃ 湿度%s%% %s%s级",
weather->city,
weather->weather,
weather->temp,
weather->humi,
weather->wind,
weather->windSpeed
);
sr_anim_set_text(str);
ESP_LOGI(TAG, "play_weather str=%s", str);
char *ttsStr = malloc(128);
memset(ttsStr, 0, 128);
sprintf(ttsStr, "%s %s\n气温%s摄氏度\n湿度百分之%s\n%s%s级",
weather->city,
weather->weather,
tempCn,
humiCn,
weather->wind,
windSpeedCn
);
tts_read(ttsStr);
}
free(str);
free(weather);
}
两个play函数的实现都是通过HTTP获取网络数据后拼装成字符串分别调用tts和设置UI文本,这里需要注意的是TTS仅支持纯中文内容,其他内容会被忽略,包括英文字母和阿拉伯数字,所以用于语音播报和UI显示的字符串需要分别进行处理,用于语音播报的字符串需要对数字和字母进行转换。
数字转汉字的函数实现:
static const char *cnNums[] = {
"零",
"一",
"二",
"三",
"四",
"五",
"六",
"七",
"八",
"九",
};
static const char *cnNumUnits[] = {
"万",
"千",
"百",
"十",
""
};
void num2cn(int number, char* dest)
{
if (number < 0 || number > 10000) {
strcpy(dest, "数字超出范围");
return;
}
char num_str[10];
sprintf(&num_str, "%d", number);
char *ns = &num_str;
for (int i = 0, len = strlen(ns); i < len; ++i) {
int num = ns[i] - 0x30; // ASCII 0-9 = 30-39
strcat(dest, cnNums[num]);
strcat(dest, cnNumUnits[5 - len + i]);
}
}
factory_demo内置的中文字体支持的字符并不多,包括LVGL内置的CJK宋体对一些符号和汉字的支持也不够,在语音播报的时候会有吞字的情况,尤其是我还计划做读书的程序就更显不足了。
于是需要自行增加一个字体,通过查询字符表之后用以下命令转成c代码就可以嵌入到我们的工程里。
lv_font_conv --font ./HarmonyOS_Sans_SC_Light.ttf -r 0x20-0x7F -r 0x2100-0x214F -r 0x3000-0x303F -r 0x4E00-0x9FFF -r 0xFE50-0xFE6F --size 16 --format lvgl --bpp 4 --no-compress -o ~/workspace/esp/espandora/main/gui/font/font_HarmonyOS_Sans_Light_16.c
这里我用的是华为的鸿蒙系统字体16像素大小,指定了常用的ASCII字符、中英文符号和中文字符等范围,足以满足日常文本内容显示的需求。
- 字符范围查询:https://jrgraphix.net/r/Unicode/
- LVGL在线图标转换:https://lvgl.io/tools/imageconverter
- LVGL字体转换程序:https://github.com/lvgl/lv_font_conv
三、固件烧录(下载)
在命令行执行idf.py flash即可完成构建并烧录,平常只修改应用逻辑的话可以用idf.py app-flash只烧录app分区,提高验证效率。后面还可以再增加monitor参数可以在烧录完成后自动开启串口监控。
四、遇到的问题
项目开发过程中遇到的主要问题还是C语言不熟悉的问题,指针越界问题频出,看见最多的错误信息就是memchr in ROM,memcpy in ROM这些了。
四、未来计划
继续完善ESP-BOX的功能,把外置的GPIO用起来,做一个功能更强大的桌面语音助手。
参考资料:
- FreeRTOS官方文档:https://www.freertos.org/zh-cn-cmn-s/freertos-core/overview.html
- ESP-IDF官方文档:https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/get-started/index.html
- Unicode字符范围表:https://jrgraphix.net/r/Unicode/