esp32s3文心一言/豆包(即火山引擎)大模型实现智能语音对话--流式语音识别_esp32s3语音识别
一、引言
在之前的帖子《Esp32S3通过文心一言大模型实现智能语音对话》中,我们介绍了如何使用Esp32S3微控制器与文心一言大模型实现基本的智能语音对话功能,但受限于语音识别技术,只能处理2-3秒的音频数据。为了提升用户体验,满足更长时间的语音聊天对话需求,本次优化采用了流式语音识别技术,并添加了语音唤醒模块,实现了语音关键词唤醒功能。
二、开发环境介绍
1、arduino开发平台;
2、所需设备:Esp32s3、inmp441、max98357、ASRPRO语音模块、ILI9488显示屏,杜邦线(接线);
3、大模型:百度的文心一言大模型,豆包的火山引擎大模型;
4、语音识别(STT)和语音合成(TTS):使用百度语音识别和语音合成;
5、使用语言:C/C++;
三、拓扑图
四、设备购买链接
1、esp32s3:
2、inmp441:
3、max98357:
4、ASRPRO语音模块:
5、扬声器:
6、杜邦线:
7、ILI9488显示屏
五、接线
1、INMP441与Esp32S3接线
1.1、inmp44介绍
INMP441是一款高性能,低功耗,数字输出,带底部端口的全向MEMS麦克风。该完整的INMP441解决方案由一个MEMS传感器,信号组成调节,模数转换器,抗混叠滤波器,电源管理和行业标准的24位I²S接口。I²S接口允许INMP441直接连接到数字处理器,如DSP和微控制器,无需使用用于系统中的音频编解码器。INMP441具有高信噪比,是一款出色的选择近场应用。 INMP441具有扁平宽带频率响应,导致自然声音高清晰度。
1.2、inmp441接口定义
SCK:I²S接口的串行数据时钟。
WS:用于I²S接口的串行数据字选择。
L/R:左/右声道选择。设置为低电平时,麦克风在I²S帧的左声道输出信号。设置为高电平时,麦克风在右声道输出信号。
SD:I²S接口的串行数据输出。
VDD:输入电源,1.8V至3.3V。
GND:电源地。
1.3、实物图
1.4、接线
2、MAX98357与Esp32S3接线
2.1、max98357介绍
这是一个采用标准的I2S作为数字音频输入,内置解码器,可将数字音频信号解码为模拟信号,并拥有内置放大器,可以直接驱动扬声器的D类放大器。因其工作效率高,可以以2.7V~5.5V的直流电压运行,因此非常适合便携式及电池供电的音频播放项目
2.2、max98357接口定义
VIN:电源正(2.5V-5.5V)。
GND:电源地。
SD:关机和频道选择。SD MODE拉低以将器件处于关断状态。
GAIN:增益和频道选择。在TDM模式下,增益固定为12dB。
DIN:数字信号输入。
BCLK:位时钟输入。
LRC:I2S与LJ模式的左/右时钟。同步时钟用于TDM模式。
2.3、实物图
2.4、接线
3、ASRPRO与Esp32S3接线
3.1、ASRPRO语音模块介绍
ASRPRO是一款高性能、低功耗的语音识别芯片,在使用过程中可以设置唤醒词和命令词。唤醒词用于将模块从待机状态切换到工作状态,防止误触发;命令词则用于执行具体的语音指令。本文中主要是借用了唤醒词的功能,使得esp32s3板子可以依赖语音唤醒。
3.2、接口定义
该模块接口可自行查资料了解。
3.3、实物图
3.4、接线
4、扬声器与MAX98357接线
这个接线比较简单,自己看着接就行。
六、源码-模块化开发
文件目录如下:
1、ASRPRO语音模块上的代码
1.1、拖拉式编程如下:
1.2、字符编程如下:
#include \"asr.h\"extern \"C\"{ void * __dso_handle = 0 ;}#include \"setup.h\"#include \"HardwareSerial.h\"uint32_t snid;String Rec;void UART_RX();void ASR_CODE();void app();//{speak:小蝶-清新女声,vol:2,speed:10,platform:haohaodada}//{playid:10001,voice:欢迎使用语音助手,用天问五幺唤醒我。}//{playid:10002,voice:我退下了,用天问五幺唤醒我}void UART_RX(){ while (1) { if(Serial.available() > 0){ Rec = Serial.readString(); if(Rec == \"on\"){ digitalWrite(4,1); Serial1.print(\"nihao xiaodi\"); } else if(Rec == \"off\"){ digitalWrite(4,0); Serial1.print(\"off\"); } } delay(2); } vTaskDelete(NULL);}/*描述该功能...*/void ASR_CODE(){ //语音识别功能框,与语音识别成功时被自动调用一次。 set_state_enter_wakeup(15000); switch (snid) { case 1: digitalWrite(4,1); break; case 2: digitalWrite(4,0); break; case 3: Serial1.print(\"nihao xiaodi\"); break; }}void app(){ //操作系统的一个线程,独立主循环任务,可支持多个类似线程任务。 //当存在多个线程任务时,注意优先级与占用内存设置。 while (1) { delay(100); } vTaskDelete(NULL);}void hardware_init(){ //需要操作系统启动后初始化的内容 vol_set(1); setPinFun(13,SECOND_FUNCTION); setPinFun(14,SECOND_FUNCTION); Serial.begin(9600); setPinFun(2,FORTH_FUNCTION); setPinFun(3,FORTH_FUNCTION); Serial1.begin(9600); Rec = \"\"; xTaskCreate(UART_RX,\"UART_RX\",128,NULL,4,NULL); xTaskCreate(app,\"app\",128,NULL,4,NULL); vTaskDelete(NULL);}void setup(){ //需要操作系统启动前初始化的内容 //{ID:0,keyword:\"唤醒词\",ASR:\"天问五幺\",ASRTO:\"我在呢\"} //{ID:3,keyword:\"唤醒词\",ASR:\"你好小迪\",ASRTO:\"在呢\"} //{ID:1,keyword:\"命令词\",ASR:\"打开继电器\",ASRTO:\"已经打开继电器\"} //{ID:2,keyword:\"命令词\",ASR:\"关闭继电器\",ASRTO:\"已经关闭继电器\"} setPinFun(4,FIRST_FUNCTION); pinMode(4,output); digitalWrite(4,0);}
2、INMP441与MAX98357初始化接口
在my_inmp441_max98357.h文件中,实现初始化inmp441与max98357的接口。
// 头文件#include #include //按照接线确定编号#define INMP441_WS 8#define INMP441_SCK 46#define INMP441_SD 9#define MAX98357_LRC 21#define MAX98357_BCLK 20#define MAX98357_DIN 19i2s_config_t inmp441_i2s_config = { .mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .bits_per_sample = i2s_bits_per_sample_t(16), .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_STAND_I2S), .intr_alloc_flags = ESP_INTR_FLAG_EDGE, .dma_buf_count = 8, // buffer 的数量 .dma_buf_len = 128 // buffer的大小,单位是i2s_bits_per_sample_t 采样位数,越小播放需要越及时时延越小,否则相反};const i2s_pin_config_t inmp441_gpio_config = { .bck_io_num = INMP441_SCK, .ws_io_num = INMP441_WS, .data_out_num = -1, .data_in_num = INMP441_SD};i2s_config_t max98357_i2s_config = { .mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_TX), .sample_rate = 16000, .bits_per_sample = i2s_bits_per_sample_t(16), .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_STAND_MSB), .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 128};const i2s_pin_config_t max98357_gpio_config = { .bck_io_num = MAX98357_BCLK, .ws_io_num = MAX98357_LRC, .data_out_num = MAX98357_DIN, .data_in_num = -1};void inmp441_max98357_setup(){ i2s_driver_install(I2S_NUM_0, &inmp441_i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_0, &inmp441_gpio_config); i2s_driver_install(I2S_NUM_1, &max98357_i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_1, &max98357_gpio_config);}void inmp441_max98357_loop() { uint16_t data[1024]; esp_err_t result; size_t bytes_read = 0; result = i2s_read(I2S_NUM_0, &data, sizeof(data), &bytes_read, portMAX_DELAY); //Serial.println(bytes_read); size_t bytes_write; result = i2s_write(I2S_NUM_1, &data, sizeof(data), &bytes_write, portMAX_DELAY);}
2、STT和TTS(语音识别和语音合成接口)
在my_stt_tts.h文件中通过百度语音识别和语音合成API接口实现语音与文字互转功能。
#include #include #include #include #include #define TXT_DATA_LEN 1024 //STT txt len#define ADC_DATA_LEN 1024*16 //read data lenconst int data_json_len = ADC_DATA_LEN * 2 * 1.4;// 1、修改百度语言技术的用户信息:https://console.bce.baidu.com/ai/?fromai=1#/ai/speech/app/listconst int STT_DEV_PID = 1537; //选填,输入法模型 1737-英语 1537-普通话(近场识别模型) 1936-普通话远程识别 1837-四川话 const char *STT_TTS_CUID = \"CoPY70iMA468o2r4PVLWmlLCruuYQd6G\"; //用户唯一标识,用来区分用户,计算UV值。建议填写能区分用户的机器 MAC 地址或 IMEI 码,长度为60字符以内。const char *STT_TTS_CLIENT_ID = \"sOKyRkOGpc76TYCNvGcd2F1i\"; //API Keyconst char *STT_TTS_CLIENT_SECRET = \"CoPY70iMA468o2r4PVLWmlLCruuYQd6G\"; //Secret KeyString stt_tts_token;String stt_tts_gainToken() { HTTPClient stt_http; String token; String url = String(\"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=\") + STT_TTS_CLIENT_ID + \"&client_secret=\" + STT_TTS_CLIENT_SECRET; stt_http.begin(url); int httpCode = stt_http.GET(); if (httpCode > 0) { String payload = stt_http.getString(); DynamicJsonDocument doc(1024); deserializeJson(doc, payload); token = doc[\"access_token\"].as(); Serial.println(\"stt:\" + token); } else { Serial.println(\"Error on HTTP request for token\"); } stt_http.end(); return token;}void stt_tts_setup(){ stt_tts_token = stt_tts_gainToken(); //Serial.println(stt_tts_token.c_str());}void stt_assembleJson(uint16_t *data, char *data_json){ if (stt_tts_token == \"\") { stt_tts_setup(); } strcat(data_json, \"{\"); strcat(data_json, \"\\\"format\\\":\\\"pcm\\\",\"); strcat(data_json, \"\\\"rate\\\":16000,\"); strcat(data_json, \"\\\"dev_pid\\\":1537,\"); strcat(data_json, \"\\\"channel\\\":1,\"); strcat(data_json, \"\\\"cuid\\\":\\\"\"); strcat(data_json, STT_TTS_CUID); strcat(data_json, \"\\\",\"); strcat(data_json, \"\\\"token\\\":\\\"\"); strcat(data_json, stt_tts_token.c_str()); strcat(data_json, \"\\\",\"); sprintf(data_json + strlen(data_json), \"\\\"len\\\":%d,\", ADC_DATA_LEN * sizeof(uint16_t)); strcat(data_json, \"\\\"speech\\\":\\\"\"); strcat(data_json, base64::encode((uint8_t *)data, ADC_DATA_LEN * sizeof(uint16_t)).c_str()); //int tmp = base64::decode((char *)adc_data, adc_data_len, data_json); strcat(data_json, \"\\\"\"); strcat(data_json, \"}\"); //Serial.println(data_json); return;}String getTextFromResponse(String response){ // Parse JSON response DynamicJsonDocument jsonDoc(1024); deserializeJson(jsonDoc, response); String outputText = jsonDoc[\"result\"]; int len = strlen(outputText.c_str()); String output = outputText.substring(2, len-2); //Serial.println(output); return output;}//待优化,合成成功,返回的Content-Type以“audio”开头, //合成出现错误,则会返回json文本,具体header信息为:Content-Type: application/jsonint getInfoFromTtsResponse(String response, LLM_MSG_RSP_T *rsp){ // Parse JSON response DynamicJsonDocument jsonDoc(1024); deserializeJson(jsonDoc, response); rsp->err_msg = (String)jsonDoc[\"err_msg\"]; //rsp->err_msg = tmp1.c_str(); rsp->err_no = jsonDoc[\"err_no\"];; //Serial.println(rsp->err_msg); //Serial.println(rsp->err_no); return rsp->err_no;}HTTPClient http_client_stt;String sendToSTT_test(uint16_t *data){ char *data_json = (char *)malloc(data_json_len*sizeof(char)); memset(data_json, \'\\0\', data_json_len*sizeof(char)); stt_assembleJson(data, data_json); int httpCode = http_client_stt.POST(data_json); free(data_json);}String sendToSTT(uint16_t *data){ HTTPClient http_client_stt; http_client_stt.begin(\"http://vop.baidu.com/server_api\");//短语音识别请求地址: 标准版http://vop.baidu.com/server_api, 极速版https://vop.baidu.com/pro_api http_client_stt.addHeader(\"Content-Type\", \"application/json\"); char *data_json = (char *)malloc(data_json_len*sizeof(char)); memset(data_json, \'\\0\', data_json_len*sizeof(char)); stt_assembleJson(data, data_json); int httpCode = http_client_stt.POST(data_json); free(data_json); if (httpCode > 0) { if (httpCode == HTTP_CODE_OK) { String response = http_client_stt.getString(); //Serial.println(response); String outputText = getTextFromResponse(response); http_client_stt.end(); return outputText; } } else { Serial.printf(\"[HTTP] POST failed, error: %s\\n\", http_client_stt.errorToString(httpCode).c_str()); http_client_stt.end(); return String(\"响应失败请重新获取!\"); }}String sendToTTS(String InputText, int *len) { InputText = urlEncode(InputText);//tex字段2次urlencode InputText = urlEncode(InputText);//百度为了更好地兼容,支持1次及2次urlencode, 其中2次urlencode可以覆盖全部的特殊字符。因而推荐传递tex 参数时做2次urlencode编码。 HTTPClient http; char* tts_url = \"https://tsn.baidu.com/text2audio\"; // 百度语音合成的API URL http.begin(tts_url); // 初始化HTTP请求 http.addHeader(\"Content-Type\", \"application/x-www-form-urlencoded\"); // 根据API要求添加HTTP头 application/x-www-form-urlencoded if (stt_tts_token == \"\") { stt_tts_setup(); } String payload = String(\"tex=\")+InputText.c_str()+String(\"&tok=\")+stt_tts_token.c_str()+String(\"&cuid=\")+STT_TTS_CUID+String(\"&ctp=1&lan=zh&spd=5&pit=1&vol=1&per=5&aue=4\"); //Serial.println(payload); String outputText; int httpCode = http.POST(payload); // 发送POST请求 if (httpCode == HTTP_CODE_OK) { String response = http.getString(); // 获取响应体 //Serial.println(response); LLM_MSG_RSP_T rsp_info; if (getInfoFromTtsResponse(response, &rsp_info)) { Serial.println(response); outputText = rsp_info.err_msg; return outputText; } *len = http.getSize(); //Serial.println(*len); http.end(); // 结束HTTP请求 return response; } else { Serial.println(\"Error in the HTTP request\"); outputText = String(\"Error in the HTTP request\"); } http.end(); // 结束HTTP请求 return outputText;}void audio_play_by_text(String input){ g_current_state |= LLM_PLAY_AUDIO_FLAG; int len = 0, i = 0, tmp = 0; String Output; uint16_t *wr_data = NULL; size_t bytes_write = 0; Output = sendToTTS(input, &len); wr_data = (uint16_t *)malloc(1024*16*sizeof(uint16_t));//1s for (i = 0; i 1024*16*sizeof(uint16_t)) tmp = 1024*16*sizeof(uint16_t); memcpy(wr_data, Output.c_str()+i, tmp); esp_err_t result = i2s_write(I2S_NUM_1, wr_data, tmp, &bytes_write, portMAX_DELAY); } free(wr_data); g_current_state &= ~LLM_PLAY_AUDIO_FLAG; return;}
3、通过API接口访问文心一言大模型
在my_ErnieBot.h文件中实现访问文心一言大模型API接口获取响应结果。
#include #include #include \"my_common.h\"// Replace with your OpenAI API keyconst char* ERNIE_BOT_CLIENT_ID = \"vCe0kXozst5OI6LC8BJNJsQ9\";//API Keyconst char* ERNIE_BOT_CLIENT_SECRET = \"3iTfEAnHRaoP0Uiml00ACw6TPFsHbFt6\";//Secret KeyString ErnieBot_accessToken;String ErnieBotGainToken() { HTTPClient http; String token; String url = String(\"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=\") + ERNIE_BOT_CLIENT_ID + \"&client_secret=\" + ERNIE_BOT_CLIENT_SECRET; http.begin(url); int httpCode = http.GET(); if (httpCode > 0) { String payload = http.getString(); DynamicJsonDocument doc(1024); deserializeJson(doc, payload); token = doc[\"access_token\"].as(); Serial.println(\"ErnieBot:\" + token); } else { Serial.println(\"Error on HTTP request for token\"); } http.end(); return token;}String getErnieBotAnswer(String inputText, int *ret) { //Serial.println(inputText.c_str()); HTTPClient http; http.setTimeout(1000000); String apiUrl = String(\"https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions?access_token=\") + ErnieBot_accessToken.c_str(); http.begin(apiUrl); http.addHeader(\"Content-Type\", \"application/json\"); String payload = \"{\\\"messages\\\":[{\\\"role\\\": \\\"user\\\",\\\"content\\\": \\\"\" + inputText + \"两百字以内。\" + \"\\\"}],\\\"disable_search\\\": false,\\\"enable_citation\\\": false}\"; Serial.println(payload.c_str()); int httpResponseCode = http.POST(payload); if (httpResponseCode == 200) { String response = http.getString(); http.end(); if (ret) *ret = ESP_SUCCEED; Serial.println(response); // Parse JSON response DynamicJsonDocument jsonDoc(1024); deserializeJson(jsonDoc, response); String outputText = jsonDoc[\"result\"]; return outputText; } else { http.end(); Serial.printf(\"Error %i \\n\", httpResponseCode); if (ret) *ret = ESP_FAILT; return \"\"; }}void ErnieBot_setup() { ErnieBot_accessToken = ErnieBotGainToken(); //Serial.println(ErnieBot_accessToken.c_str()); String answer = getErnieBotAnswer(\"你好,文心一言\", NULL); Serial.println(\"<Test Answer: \" + answer);}
4、通过API接口访问豆包(火山引擎)大模型
在my_Doubao.h文件中实现访问文心一言大模型API接口获取响应结果。
#include #include #include \"my_common.h\"// Replace with your OpenAI API keyconst char* doubao_apiKey = \"4ab25fbb-ce6c-4f02-95f6-63073227d141\";String getDoubaoAnswer(String inputText, int *ret) { //Serial.println(inputText.c_str()); HTTPClient http; http.setTimeout(1000000); String apiUrl = \"https://ark.cn-beijing.volces.com/api/v3/chat/completions\"; http.begin(apiUrl); http.addHeader(\"Content-Type\", \"application/json\"); String token_key = String(\"Bearer \") + doubao_apiKey; http.addHeader(\"Authorization\", token_key); //256k上下文推理: ep-20241230152833-5fcsh //快速响应: ep-20241230144301-t84jj String payload = \"{\\\"model\\\":\\\"ep-20241230152833-5fcsh\\\",\\\"messages\\\":[{\\\"role\\\":\\\"system\\\",\\\"content\\\":\\\"你是我的AI助手vor,你必须用中文回答且字数不超过85个\\\"},{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"\" + inputText + \"\\\"}],\\\"temperature\\\": 0.3}\"; //Serial.println(payload.c_str()); int httpResponseCode = http.POST(payload); if (httpResponseCode == 200) { String response = http.getString(); http.end(); if (ret) *ret = ESP_SUCCEED; //Serial.println(response); // Parse JSON response DynamicJsonDocument jsonDoc(1024); deserializeJson(jsonDoc, response); String outputText = jsonDoc[\"choices\"][0][\"message\"][\"content\"]; return outputText; } else { http.end(); Serial.printf(\"Error %i \\n\", httpResponseCode); if (ret) *ret = ESP_FAILT; return \"\"; }}void Doubao_setup() { String answer = getDoubaoAnswer(\"你好,豆包!\", NULL); Serial.println(\"<Test Answer: \" + answer);}
5、WiFi模块
在my_wifi.h文件中初始化wifi功能。
#include void wifi_setup() { Serial.println(\"\\n-- Start connecting to WiFi! --\"); WiFi.disconnect(true); // 3、填写您的wifi账号密码 WiFi.begin(\"wifi name\", \"password\"); while (WiFi.status() != WL_CONNECTED) { Serial.print(\".\"); vTaskDelay(200); } Serial.println(\"\\n-- wifi connect success! --\");}
6、common文件
在my_common.h文件中定义各个模块共同调用的参数。
#ifndef MY_COMMON#define MY_COMMON#if 1#include #include \"base64.h\"#include #include \"HTTPClient.h\"#include #include #include #include #include \"freertos/FreeRTOS.h\"#include \"freertos/task.h\"// 与AP模式和Web服务器有关的库#include #include #include #endif#define ESP_SUCCEED 0#define ESP_FAILT 1#define BIT(n) (1LLU << (n))#define SET_BIT(mask, n) ((mask) |= BIT(n))#define RESET_BIT(mask, n) ((mask) &= ~BIT(n))#define ISSET_BIT(mask, n) (!!((mask)&BIT(n)))#define LLM_STANDBY_MODE BIT(1) //待机状态标记#define LLM_CONTINUOUS_DIALOGUE_STATE BIT(2) //连续对话状态标记#define LLM_TTS_STANDBY_STATE BIT(3) //TTS线程进入待机状态#define LLM_RECORDING_STANDBY_STATE BIT(4) //音频数据采集线程进入待机状态#define LLM_REAWAKEN_FLAG BIT(5) //重复唤醒标记#define LLM_TTS_POLL_FLAG BIT(6) //POLL task结束标记#define LLM_PLAY_AUDIO_FLAG BIT(7) //音频播放状态,置位1--播放中#define LLM_FIRST_RSP_FLAG BIT(8) //大模型响应标记,第一个响应后置位,即哪个模型响应快使用哪个做TTSint g_current_state = 0;typedef struct LLM_MSG_RSP{ String err_msg; int err_no;}LLM_MSG_RSP_T;#endif
7、核心逻辑代码文件
在esp32_ai_llm.h文件中编码实现调用各个模块接口代码,把各个模块功能串联起来实现最终语音唤醒以及连续对话的功能。
流式语音识别逻辑目前在该文件中,后续会摘出来单独作为一个模块。
七、效果展示
整体流程已经基本调通,效果展示后面拍好视频后补上来。
各模块源码无保留在第五章节,loop()函数各位老铁自己调用模块接口实现。
从无到有实现目前的效果,有参考其他大佬的帖子,也有自己摸索实现的部分,知识劳动成果,实属不易。
如果需要技术支持,欢迎骚扰(+v:Sw-striving)!