[Funpack2-5]基于ESP32-S3的一个语音播报系统
Funpack活动项目,基于ESP32-Box-lite实现的语音交互小盒子。使用ESP32的WiFi和TTS功能,实现一个语音播报系统,如联网获取粉丝数并播报或者获取天气并播报。
标签
Funpack活动
ESP32
ESP32-S3-BOX-LITE
TTS
Hessian
更新2023-08-01
882

[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™兼容接口支持扩展外设模块
  • 提供了大量的使用说明和开发案例

FqgbusVGlFhZRDUygz3oaYTLKYa8

项目介绍

这次的项目我命名为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

Fou-z2IhoXpJe_5cFH4SwHTQZbHi

表 1关键要配置好环境变量

 

二、程序设计与实现

 

软件流程图

 FpnM7vWkMHxYudZappN2pluECwWB

工程目录结构

FpsL31izlCDg882J5h7monlzTFBW

语音识别与语音合成

该项目使用了乐鑫 提供的 ESP-SR进行语音识别,使用ESP-TTS进行语音合成以完成语音播报功能。

乐鑫 TTS 语音合成模型是一个为嵌入式系统设计的轻量化语音合成系统,具有如下主要特性:

 

  • 目前 仅支持中文
  • 输入文本采用 UTF-8 编码
  • 输出格式采用流输出,可减少延时
  • 多音词发音自动识别
  • 可调节合成语速
  • 数字播报优化
  • 自定义声音集(敬请期待)

 

乐鑫 TTS 的当前版本基于拼接法,主要组成部分包括:

解析器 (Parser):根据字典与语法规则,将输入文本(采用 UTF-8 编码)转换为拼音列表。

合成器 (Synthesizer):根据解析器输出的拼音列表,结合预定义的声音集,合成波形文件。默认输出格式为:单声道,16 bit @ 16000Hz。

 

系统框图如下:

FuUj4sNSbzjVD4MqOEOo0fQGukBy

 

通过idf.py menuconfig我们可以配置ESP-SR选择唤醒词。

Fuc6QznD1wagpoP435Rg_hbqNhLnFjY21tojfvV47pBKUi1l8MSXQlP3

通过上图菜单中的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的官方文档。

参考文档:

 

完成以上工作之后我们就可以开始进行语音识别的编码部分了。

首先要添加两个语音指令用于查询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字符、中英文符号和中文字符等范围,足以满足日常文本内容显示的需求。

 

三、固件烧录(下载)

在命令行执行idf.py flash即可完成构建并烧录,平常只修改应用逻辑的话可以用idf.py app-flash只烧录app分区,提高验证效率。后面还可以再增加monitor参数可以在烧录完成后自动开启串口监控。

 

FteizpMvLb9BDsnHGCD8Db8Y2TQW

四、遇到的问题

项目开发过程中遇到的主要问题还是C语言不熟悉的问题,指针越界问题频出,看见最多的错误信息就是memchr in ROM,memcpy in ROM这些了。

四、未来计划

继续完善ESP-BOX的功能,把外置的GPIO用起来,做一个功能更强大的桌面语音助手。

 

参考资料:

 

附件下载
espandora.zip
完整项目源码
团队介绍
囧大大王(周海生)
团队成员
Hessian
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号