任务介绍
本项目实现了Funpack第2-5期活动的任务一,在ESP32-S3-Box-Lite平台上,使用WiFi和TTS,实现了天气获取与播报,并进一步实现了基于百度文心一言的智能聊天机器人。
硬件平台
首先介绍本期活动的主角,ESP32-S3-Box-Lite开发板,这是一块集成度很高的嵌入式开发板,主控是我们很熟悉的ESP32微控制器。但和以往不一样的是,它的型号是最新的S3,不仅含有两颗可以达到240M主频的CPU,还集成了AI加速单元,可玩性很强。除了内置SRAM和Flash之外,还外接了16M的Flash以及片外SRAM,量大管饱,可以尽情折腾AI、音视频这种消耗资源的应用。开发板同时配备了屏幕、双麦克风、编解码芯片,所以具备了完整的音频输入输出能力。我很早就想体验一下它的AI和音视频能力,这次正好借着Funpack活动顺利上车。
任务分析与实现
因为这块板子的设计定位就是AI和音视频应用,这次我也主要是想体验一下最新的ESP32S3在这方面到底表现如何。所以我选择了任务一,并在它基础上增加难度。做了一个基于ChatGPT的语音聊天机器人。下面我们做个功能拆解,语音聊天方面,需要语音识别、智能聊天、TTS文本转语音三个子流程进行配合。需要做音频输入输出,这就要驱动编解码模块。底层驱动官方已经做好了,我们直接使用就行。
项目的系统功能框图如下
- 第一个难点是要识别有人在说话。我们不能让他一直录音,给语音识别接口发数据,这样费电、废流量、还费钱。网上许多程序是用按键来控制,按下去就开始录音,松手就停止。这样还是有点笨,很不方便。这次还好声学前端里实现了VAD算法,也就是语音活动检测,我们可以用算法来自动控制录音的开关,在esp-ADF里有相关的例程。在程序中,我首先使用Wakenet他网络做了一个唤醒检测,当机器唤醒后,使用VAD算法进行语音活动检测,VAD算法监测到我们在说话后,就给录音线程发信号,启动录音。我们停止说话后,VAD发信号停止录音。我们说的话就被完整地录下来,发送给你百度语音识别接口,第一步语音识别就完成了。
static esp_err_t rec_engine_cb(audio_rec_evt_t type, void *user_data) { if (AUDIO_REC_WAKEUP_START == type) { ESP_LOGI(TAG, "rec_engine_cb - AUDIO_REC_WAKEUP_START"); duer_dcs_audio_sync_play_tone("spiffs://spiffs/dingding.wav"); } else if (AUDIO_REC_VAD_START == type) { ESP_LOGI(TAG, "rec_engine_cb - AUDIO_REC_VAD_START"); xEventGroupSetBits(duer_evt, TASK_REC_READING); } else if (AUDIO_REC_VAD_END == type) { xEventGroupClearBits(duer_evt, TASK_REC_READING); xEventGroupSetBits(duer_evt, TASK_UPLOAD_START); ESP_LOGI(TAG, "rec_engine_cb - AUDIO_REC_VAD_STOP, state"); } else if (AUDIO_REC_WAKEUP_END == type) { ESP_LOGI(TAG, "rec_engine_cb - AUDIO_REC_WAKEUP_END"); } return ESP_OK; }
下图是语音唤醒与识别的过程。 - 第二个难点是TTS功能的实现,我们既可以用ESP32官方写的TTS库,也可以用第三方的接口,比如微软的Edge-TTS、百度TTS。这里我先试了一下ESP32官方的TTS,毕竟ESP32能自己完成的,就最好别依赖第三方接口。我用官方的skainet工程体验了一下,感觉只能说是勉强能用。首先是合成的语音有时候断句不准确、对符号的发音有缺失,最致命的是输入句子还不能太长。所以只适合做一些例如收付款、家电控制这种固定的短句场景。而在我们的ChatGPT聊天助手的场景中,因为你不知道AI要回答什么,回答的长度也不固定,只能通过GPT的Prompt提示来做个概略的限制。所以我们需要TTS的能力更强一些。因此我最后选择了调用网络服务的方式。
在上述代码中我们使用edge-tts的Python库,使用FastAPI框架搭建了一个简易的Edge-TTS服务。服务中首先使用传入的文字生成对应的mp3文件,随后将该文件传输给请求方,ESP-BOX-LITE收到该文件后,esp-adf将会调用自身的功能,边接收mp3流,边解码播放。from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn import edge_tts OUTPUT_FILE = "C:/Files/Codes/Python/tts/test.mp3" app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) async def get_file(text): communicate = edge_tts.Communicate(text, 'zh-CN-YunxiNeural', rate="-10%") await communicate.save(OUTPUT_FILE) @app.get('/tts') async def tts(text: str = '不好意思,请重新问一遍'): await get_file(text) return FileResponse(OUTPUT_FILE, media_type='audio/mp3') if __name__ == '__main__': uvicorn.run(app, host="0.0.0.0", port=5000)
- 任务第三个难点是语音识别、智能聊天、TTS文本转语音这三个网络接口的适配,我使用的是百度语音识别、文心一言聊天接口、和微软的Egde-TTS。这三种接口反应最快的是Edge-TTS,因为它搭建在局域网我的笔记本电脑上。其次是百度语音识别,最差的就是聊天接口,ChatGPT、Claude这些接口需要魔法,延迟也很高。另外我的ChatGPT的APIKey到期了。所以最后选择了百度文心一言的接口,至少延迟方面有保证。最后要提一点,我现在的程序版本中,这三个接口都是阻塞式调用的。网络传输的延迟加上接口本身的响应延迟还是比较明显。在后续的改进版本中,最好是改成基于WebSocket协议的流式调用,边聊天边传输。那些成熟的产品都是这样的方案。
下面是本项目三个核心线程的代码,可以看到它们内部的主要功能就是发起网络请求,并将请求结果填入相应的缓冲区。其中百度ASR(即语音识别服务)需要请求百度的服务器,而TTS和GPT服务则部署在局域网的其他电脑上。#define TTS_BASE_URL "http://192.168.1.7:5000/tts?text=%s" #define GPT_BASE_URL "http://192.168.1.7:6000/chat" #define BAIDU_ASR_URL "http://vop.baidu.com/server_api?dev_pid=1537&cuid=ESP32&token=" static void chatgpt_task(void *args) { bool runing = true; while (runing) { EventBits_t bits = xEventGroupWaitBits(duer_evt, TASK_CHATGPT_START, false, true, portMAX_DELAY); if (bits & TASK_CHATGPT_START) { ESP_LOGI("chatgpt_task", LOG_BOLD(LOG_COLOR_BLUE) "TASK_CHATGPT_START url:%s\tload:%s", GPT_BASE_URL, gpt_post_load); http_request(CHATGPT_REQUEST); xEventGroupClearBits(duer_evt, TASK_CHATGPT_START); ESP_LOGI("chatgpt_task", LOG_BOLD(LOG_COLOR_BLUE) "TASK_CHATGPT_START FINISHED"); } } xEventGroupClearBits(duer_evt, TASK_CHATGPT_START); vTaskDelete(NULL); } static void player_task(void *args) { char *tts_url = audio_calloc(1, 512); bool runing = true; while (runing) { EventBits_t bits = xEventGroupWaitBits(duer_evt, TASK_PLAYER_START, false, true, portMAX_DELAY); if (bits & TASK_PLAYER_START) { memset(tts_url, 0, 512); sprintf(tts_url, TTS_BASE_URL, tts_post_load); ESP_LOGI("player_task", LOG_BOLD(LOG_COLOR_BLUE) "TASK_PLAYER_START url:%s", tts_url); esp_audio_play(player, AUDIO_CODEC_TYPE_DECODER, (const char *)tts_url, 0); xEventGroupClearBits(duer_evt, TASK_PLAYER_START); ESP_LOGI("player_task", LOG_BOLD(LOG_COLOR_BLUE) "TASK_PLAYER_START FINISHED"); } } free(tts_url); xEventGroupClearBits(duer_evt, TASK_PLAYER_START); vTaskDelete(NULL); } static void baidu_asr_task(void *args) { bool runing = true; while (runing) { EventBits_t bits = xEventGroupWaitBits(duer_evt, TASK_UPLOAD_START, false, true, portMAX_DELAY); if (bits & TASK_UPLOAD_START) { ESP_LOGI("baidu_asr_task", "TASK_UPLOAD_START"); http_request(BAIDU_VOICE_REQUEST); xEventGroupClearBits(duer_evt, TASK_UPLOAD_START); ESP_LOGI("baidu_asr_task", "TASK_UPLOAD_START END"); } } xEventGroupClearBits(duer_evt, TASK_UPLOAD_START); free(voiceData); vTaskDelete(NULL); } static void http_request(request_type_t type) { if (type == BAIDU_VOICE_REQUEST) { get_access_token(); esp_http_client_config_t config = { .url = baidu_asr_url, .method = HTTP_METHOD_POST, .timeout_ms = 5000, .event_handler = _http_baidu_voice_event_handler, }; esp_http_client_handle_t client = esp_http_client_init(&config); esp_http_client_set_header(client, "Content-Type", "audio/pcm;rate=16000"); esp_http_client_set_post_field(client, (const char *)voiceData, voiceDataLoc); esp_err_t err = esp_http_client_perform(client); if (err == ESP_OK) { ESP_LOGI(TAG, "HTTP POST Status = %d, content_length = %d", esp_http_client_get_status_code(client), esp_http_client_get_content_length(client)); } else { ESP_LOGE(TAG, "HTTP POST request failed: %s", esp_err_to_name(err)); } esp_http_client_cleanup(client); voiceDataLoc = 0; } else if (type == CHATGPT_REQUEST) { esp_http_client_config_t config = { .url = GPT_BASE_URL, .method = HTTP_METHOD_POST, .timeout_ms = 5000, .event_handler = _http_chatgpt_event_handler, }; esp_http_client_handle_t client = esp_http_client_init(&config); ESP_LOGI(TAG, "[gpt_post_load]: %s, len: %d", gpt_post_load, strlen(gpt_post_load)); esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_post_field(client, (const char *)gpt_post_load, strlen(gpt_post_load)); esp_err_t err = esp_http_client_perform(client); if (err == ESP_OK) { ESP_LOGI(TAG, "HTTP POST Status = %d, content_length = %d", esp_http_client_get_status_code(client), esp_http_client_get_content_length(client)); } else { ESP_LOGE(TAG, "HTTP POST request failed: %s", esp_err_to_name(err)); } esp_http_client_cleanup(client); } else { ESP_LOGE(TAG, "http_request type error"); return; } }
因为平时不停地接触各种项目,我越来越发现一个好的UI界面,对于项目是非常加分的。但是我没有正式地学过UI设计,也没成体系地练习过PS、AI这种设计工具。所以在UI界面上,我使用了X-TRACK框架,它是基于MVC架构设计的。作者的代码能力很强,还实现了页面生命周期管理、消息发布订阅、资源中心,框架层、应用层和HAL硬件抽象层的分离,整套代码就是一个字:漂亮。我对它的页面做了一些魔改,主页显示各种天气信息。
效果展示
活动感想
最后,感谢硬禾学堂和得捷电子联合举办的Funpack活动,祝硬禾的活动越办越好!