文章目录
Toggle前言
在上一篇【项目实战】基于esp32开发板+大模型实现的陪伴助手-模型篇中,我们完成了唤醒词训练、评估,并将唤醒词识别集成至DSP32开发板。
本章,我们将重点梳理项目中涉及到的软件部分,包括前端和后端实现内容。
回顾
正如上图所示,项目整体架构分为前端和后端两部分。
- 前端(DSP32开发板):负责用户交互,实现监听唤醒词,进行音频录制、音频播放等功能。
- 后端(百度API部分):通过HTTP请求,实现语音识别(ASR)、语音合成(TTS)等功能。
- 后端(Dify部分):通过HTTP请求,实现大模型对话功能。
接下来,我们分别梳理前端和后端实现内容。
1. 前端(DSP32开发板)
1.1 基础知识
因为Arduino 程序运行的基本框架是有两个函数构成:
setup()
:初始化设置,设备启动时,只运行一次。loop()
:主循环,持续循环运行该函数。
以Arduino预置的Blink示例代码为例:
// 初始化设置
void setup() {
Serial.begin(115200); // 初始化串口
// 初始化LED引脚
pinMode(LED_BUILTIN, OUTPUT);
}
// 主循环
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // 点亮LED
delay(1000); // 延时1秒
digitalWrite(LED_BUILTIN, LOW); // 熄灭LED
delay(1000); // 延时1秒
Serial.println("this is blink test"); // 串口打印日志
}
1.2 工作流程
基于上述基础知识,我们可以将DSP32开发板的工作流程绘制如下:
graph TD
A[设备启动] --> B[初始化设置]
B --> C[WiFi连接]
B --> D[I2S初始化]
B --> E[创建mainChat线程]
%% 唤醒词检测流程
F[循环监听] --> G{检测唤醒词}
G -->|未检测到| F
G -->|检测到| H[LED指示灯亮起]
H --> I[播放应答音]
I --> J[设置record_status为false]
%% 主对话流程
E --> K[mainChat线程]
K --> L{检查record_status}
L -->|true| K
L -->|false| M[录音处理]
%% 录音和识别流程
M --> N[分配内存]
N --> O[开始录音]
O --> P{检测声音}
P -->|无声音超时| Q[结束录音]
P -->|有声音| O
Q --> R{是否有效录音}
R -->|无效| S[LED关闭]
S --> T[释放内存]
T --> K
%% 语音处理流程
R -->|有效| U[音频转文本STT]
U --> V[调用AI对话]
V --> W[文本转语音TTS]
W --> X[播放回复]
X --> T
说明:
- 设备启动时:
-
- 进行初始化设置:包括WiFi连接、I2S初始化、创建mainChat线程。
-
- mainChat线程:持续循环运行,检查record_status状态,若为false,则进行录音处理以及后续的应答;若为true,则返回mainChat线程。
-
- 监听唤醒词线程:循环监听唤醒词,若检测到唤醒词,则点亮LED指示灯,播放应答音,并设置record_status为false(以便触发mainChat线程的工作)。
-
1.3 关键代码
1.3.1 初始化设置
代码文件:esp32s3-ai-chat\esp32s3-ai-chat.ino
#include <wakeup_voice_inferencing.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <driver/i2s.h>
#include <UrlEncode.h>
#include <base64.hpp>
#include "config.h" // 包含配置头文件
// Audio recording settings
#define SAMPLE_RATE 16000
#define RECORD_TIME_SECONDS 15
#define BUFFER_SIZE (SAMPLE_RATE * RECORD_TIME_SECONDS)
static bool debug_nn = false; // Set this to true to see e.g. features generated from the raw signal
static bool record_status = true;
void setup() {
// 设置串口波特率
Serial.begin(115200);
// 设置LED输出模式,并初始化设置为低
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW); //Turn off
// Connect to WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("[INFO][Setup]Connecting to WiFi...");
}
Serial.println("[INFO][Setup]Connected to WiFi");
// 初始化I2S
initI2S();
Serial.println("[INFO][Setup]I2S initialized");
// 开启对话主流程
Serial.println("[INFO][Setup]Starting mainChat task.");
xTaskCreate(mainChat, "mainChat", 1024 * 32, NULL, 10, NULL);
// summary of inferencing settings (from model_metadata.h)
ei_printf("[INFO][Setup]Inferencing settings:\n");
ei_printf("\tInterval: ");
ei_printf_float((float)EI_CLASSIFIER_INTERVAL_MS);
ei_printf(" ms.\n");
ei_printf("\tFrame size: %d\n", EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE);
ei_printf("\tSample length: %d ms.\n", EI_CLASSIFIER_RAW_SAMPLE_COUNT / 16);
ei_printf("\tNo. of classes: %d\n", sizeof(ei_classifier_inferencing_categories) / sizeof(ei_classifier_inferencing_categories[0]));
ei_printf("\n[INFO][Setup]Starting continious inference in 2 seconds...\n");
ei_sleep(2000);
if (microphone_inference_start(EI_CLASSIFIER_RAW_SAMPLE_COUNT) == false) {
ei_printf("[ERR][Setup]Could not allocate audio buffer (size %d), this could be due to the window length of your model\r\n", EI_CLASSIFIER_RAW_SAMPLE_COUNT);
return;
}
ei_printf("[INFO][Setup]Recording...\n");
}
说明:
config.h
:包含WiFi连接、I2S初始化、创建mainChat线程等配置信息。wakeup_voice_inferencing.h
:是由训练好的模型导入后的库文件。initI2S()
对应esp32s3-ai-chat/audio_inference.ino
定义的初始化I2S函数。microphone_inference_start()
对应esp32s3-ai-chat/audio_inference.ino
定义的开始录音函数。- 为了方便调试,我们通过
Serial.println
打印日志,[INFO]表示重点步骤的信息,[ERR]表示错误信息。
1.3.2 mainChat线程
代码文件:esp32s3-ai-chat/esp32s3-ai-chat.ino
void mainChat(void* arg) {
//获取access token
String baidu_access_token = "";
String qianfan_access_token = "";
baidu_access_token = getAccessToken(baidu_api_key, baidu_secret_key);
#if USE_QIANFAN_MODEL
qianfan_access_token = getAccessToken(qianfan_api_key, qianfan_secret_key);
Serial.println("[INFO][mainChat]Qianfan access token retrieved.");
#endif
Serial.println("[INFO][mainChat]Access tokens retrieved.");
while (1) {
if (!record_status) {
// Record audio from INMP441
// 分配内存
uint8_t* pcm_data = (uint8_t*)ps_malloc(BUFFER_SIZE);
if (!pcm_data) {
Serial.println("[ERR][mainChat]Failed to allocate memory for pcm_data");
return;
}
Serial.println("[INFO][mainChat]Memory allocated for pcm_data."); // 添加调试日志
Serial.println("[INFO][mainChat]i2s_read");
// 开始循环录音,将录制结果保存在pcm_data中
size_t bytes_read = 0, recordingSize = 0, ttsSize = 0;
int16_t data[512];
size_t noVoicePre = 0, noVoiceCur = 0, noVoiceTotal = 0, VoiceCnt = 0;
bool recording = true;
while (1) {
// 记录刚开始的时间
noVoicePre = millis();
// 使用封装的函数替代原始的I2S读取代码
bytes_read = readAudioData(data, pcm_data, recordingSize);
recordingSize += bytes_read;
// Serial.printf("%x recordingSize: %d bytes_read :%d\n", pcm_data + recordingSize, recordingSize, bytes_read);
// 计算平均值
uint32_t sum_data = 0;
for (int i = 0; i < bytes_read / 2; i++) {
sum_data += abs(data[i]);
}
sum_data = sum_data / bytes_read;
Serial.printf("[INFO][mainChat]Average sound level: %d\n", sum_data);
// 判断当没有说话时间超过一定时间时就退出录音
noVoiceCur = millis();
if (sum_data < 15) {
noVoiceTotal += noVoiceCur - noVoicePre;
} else {
noVoiceTotal = 0;
VoiceCnt += 1;
}
Serial.printf("[INFO][mainChat]No voice duration: %d ms\n", noVoiceTotal);
if (noVoiceTotal > 1000) {
recording = false;
}
if (!recording || (recordingSize >= BUFFER_SIZE - bytes_read)) {
Serial.printf("[INFO][mainChat]Recording done: %d\n", recordingSize);
break;
}
}
// 设置唤醒录音状态为true,此后可以唤醒
record_status = true;
// 此时一直没有说话,则退出被唤醒状态
if (VoiceCnt == 0) {
digitalWrite(LED_BUILTIN, LOW); //Turn off
Serial.println("[INFO][mainChat]No voice detected, turning off LED.");
recordingSize = 0;
// 释放内存
free(pcm_data);
continue;
}
if (recordingSize > 0) {
// 音频转文本(语音识别API访问)
String recognizedText = baiduSTT_Send(baidu_access_token, pcm_data, recordingSize);
Serial.println("[INFO][mainChat]Recognized text: " + recognizedText);
// 根据宏定义选择使用的大模型
String aiResponse;
#if USE_QIANFAN_MODEL
aiResponse = baiduErnieBot_Get(qianfan_access_token, recognizedText.c_str());
Serial.println("[INFO][mainChat]Qianfan Bot response: " + aiResponse);
#else
aiResponse = difyChat_Send(recognizedText);
Serial.println("[INFO][mainChat]Dify Bot response: " + aiResponse);
#endif
// 文本转音频tts并通过MAX98357A输出(语音合成API访问)
baiduTTS_Send(baidu_access_token, aiResponse);
Serial.println("[INFO][mainChat]TTS processing completed.");
}
// 释放内存
free(pcm_data);
Serial.println("[INFO][mainChat]Memory for pcm_data freed.");
// 设置唤醒录音状态为false,此后继续录音对话
record_status = true; // 设置为true看看问题是不是在这里
}
delay(1000);
}
}
说明:
baiduSTT_Send()
对应esp32s3-ai-chat/server_api.ino
中定义的百度语音识别API。baiduErnieBot_Get()
对应esp32s3-ai-chat/server_api.ino
中定义的百度大模型API。difyChat_Send()
对应esp32s3-ai-chat/server_api.ino
中定义的Dify大模型API。baiduTTS_Send()
对应esp32s3-ai-chat/server_api.ino
中定义的百度语音合成API。
1.3.3 监听唤醒词
代码文件:esp32s3-ai-chat/esp32s3-ai-chat.ino
void loop() {
bool m = microphone_inference_record();
if (!m) {
ei_printf("[ERR][Loop]Failed to record audio...\n");
return;
}
ei_printf("[INFO][Loop]Audio recorded successfully.\n"); // 添加调试日志
signal_t signal;
signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
signal.get_data = µphone_audio_signal_get_data;
ei_impulse_result_t result = { 0 };
EI_IMPULSE_ERROR r = run_classifier(&signal, &result, debug_nn);
if (r != EI_IMPULSE_OK) {
ei_printf("[ERR][Loop]Failed to run classifier (%d)\n", r);
return;
}
ei_printf("[INFO][Loop]Classifier run successfully.\n"); // 添加调试日志
int pred_index = -1; // Initialize pred_index
float pred_value = PRED_VALUE_THRESHOLD; // Initialize pred_value
// print the predictions
ei_printf("[INFO][Loop]Predictions ");
ei_printf("(DSP: %d ms., Classification: %d ms., Anomaly: %d ms.)",
result.timing.dsp, result.timing.classification, result.timing.anomaly);
ei_printf(": \n");
for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
ei_printf(" %s: ", result.classification[ix].label);
ei_printf_float(result.classification[ix].value);
ei_printf("\n");
// 唤醒词在第一位,此时判断classification[0]位置大于阈值表示唤醒,如果你的唤醒词在第2位,则需要判断classification[1],第3位就是classification[2]
if (result.classification[0].value > pred_value) {
pred_index = 0;
ei_printf("[INFO][Loop]Wake word detected.\n");
}
}
// Display inference result
if (pred_index == 0) {
digitalWrite(LED_BUILTIN, HIGH); //Turn on
ei_printf("[INFO][Loop]LED turned ON for wake word.\n");
Serial.println("[INFO][Loop]playAudio_Zai");
playAudio_Zai();
record_status = false;
}
#if EI_CLASSIFIER_HAS_ANOMALY == 1
ei_printf(" anomaly score: ");
ei_printf_float(result.anomaly);
ei_printf("\n");
#endif
}
说明:
PRED_VALUE_THRESHOLD
对应唤醒词的阈值,默认设置为0.8;当result.classification[0].value
大于阈值时,表示检测到唤醒词。microphone_inference_record()
对应esp32s3-ai-chat/audio_inference.ino
中定义的录音函数。playAudio_Zai()
对应esp32s3-ai-chat/audio_data.ino
中定义的播放唤醒词音频函数,它是一段base64编码的音频,预置在程序中以便快速读取调用。
2. 后端(百度API部分)
2.1 注册百度语音处理API并获取API-KEY
操作步骤:
-
访问百度智能云官网并注册登录。https://cloud.baidu.com/
备注:
- 开启语音合成方式与语音识别类似,不再赘述。
- 百度语音识别和语音合成可以使用同一个API-KEY,但需要分别开通。
2.3 调试API接口
操作步骤:
2.3.1 获取鉴权信息
操作步骤:
2.3.2 测试语音识别
操作步骤:
说明:
- 在测试页面中,上传的音频文件会被
base64
编码,所以我们在DSP开发板中,也需要实现将录制的音频文件进行base64
编码。
2.3.3 测试语音合成
操作步骤:
-
点击 语音合成->短文本在线合成
3. 后端(Dify部分)
3.1 部署Dify服务
部署步骤:
- 准备一台服务器
- 安装Docker
- 安装Docker-Compose
- 配置镜像源
- 拉取Dify代码(https://github.com/langgenius)
- 配置Dify
- 启动Dify镜像
以上详细步骤在网上可找到详细资料,也可参考【产品体验】使用dify部署微信群聊天机器人,本章不再赘述。
3.2 创建Dify项目
3.2.1 创建空白项目
说明:
Dify平台提供了辅助生成prompt的功能,点击右上角的生成,输入需求后,平台会自动生成prompt。
说明:
- Dify平台默认是GPT3.5,需要有API-KEY;
- 如果需要使用其他大模型,可以在模型列表中选择对应模型并配置API-KEY。
- 本章示例中配置的是硅基(硅基平台集成了多种模型并统一管理,免去了去各个模型平台注册账号的麻烦,具体方法可参考【产品体验】使用dify部署微信群聊天机器人)
3.3 创建API-KEY
由于DSP后续需要调用该API,Dify服务部署在公网(部署在本地或局域网不涉及此问题),为了安全起见,我们需要给应用创建一个API-KEY,以便进行API访问的鉴权。
操作步骤:
说明:
- 右上角的
http://dify.17aitech.com/v1
是API地址,其中dify.17aitech.com
是Dify服务器地址,请读者根据实际情况修改为对应的IP地址或者域名。
4. 前后端联调
4.1 配置DSP中的地址和API-KEY
代码路径:esp32s3-ai-chat/config.cpp
// config.cpp
#include "config.h"
// WiFi credentials
const char* ssid = "HUAWEI_E2D1";
const char* password = "xxxxx";
// Baidu API credentials
const char* baidu_api_key = "Ulxxxx";
const char* baidu_secret_key = "0rjxxxxxx";
// Dify API credentials
const char* DIFY_API_KEY = "app-xxxxxx";
说明:
ssid
、password
对应 WiFi名称和密码。baidu_api_key
、baidu_secret_key
对应 2.1 中的API-KEY。DIFY_API_KEY
对应 3.3 中的API-KEY。
代码路径: esp32s3-ai-chat/server_api.ino
// server_api.ino
// API URLs
const char* BAIDU_TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token";
const char* BAIDU_STT_URL = "http://vop.baidu.com/server_api";
const char* BAIDU_TTS_URL = "https://tsn.baidu.com/text2audio";
const char* DIFY_API_URL = "http://dify.17aitech.com/v1/chat-messages";
说明:
BAIDU_TOKEN_URL
对应 2.3.1 中的API地址。BAIDU_STT_URL
对应 2.3.2 中的API地址。BAIDU_TTS_URL
对应 2.3.3 中的API地址。DIFY_API_URL
对应 3.2 中的API地址,其中dify.17aitech.com
是Dify服务器地址,请读者根据实际情况修改为对应的IP地址或者域名。
4.2 编译并上传程序
不足之处
- 响应速度问题。该项目中的语音识别、大模型响应、语音合成需要发起3次HTTP请求,导致响应速度较慢,无法达到实时语音沟通。后续可以考虑将语音识别和语音合成集成到DSP中,以减少对网络的依赖。
- 对话体验问题。该项目中目前的对话方式类似于小爱音箱,需要通过唤醒词触发对话,无法做到多轮对话以及实时对话,需要后续调研实时对话API。
- 多用户管理问题。该项目目前仍然只实现了一个DSP与DIFY服务的对话,还无法做到多个用户与DIFY服务的内容隔离,需要后续增加用户管理功能。
- 唤醒词问题。该项目中目前的唤醒词准确率仍然较低,需要后续继续优化唤醒词的神经网络。
总结
- 本项目实现了基于DSP32开发板+Dify服务+百度API的语音助手,基本实现了从硬件→后端服务的基本流程。
- 硬件部分:
- 通过DSP32开发板实现了语音识别、大模型响应、语音合成。
- 通过MAX98357A实现了音频输出。
- 通过麦克风实现了音频输入。
- 后端部分:
- 通过Dify服务实现了对话内容。
- 通过百度API实现了语音识别、语音合成。
- 模型部分:
- 将音频采集并且预处理后的信号数据,进行特征提取
- 通过多层神经网络进行模型训练,实现了唤醒词识别
该系列其他文章
欢迎关注公众号以获得最新的文章和新闻