项目介绍
我要DIY的项目是一个接入了大模型的语音聊天BOX, 需要满足的功能是: ESP-32-BOX-3 可以接受用户音频的输入, 然后调用大模型并且获取到大模型的回复. 在前几篇文章中,我们已经满足了基本上所有完成聊天盒的基本功能. 例如音频录制、音频播放、屏幕显示、和WIFI连接等等. 在本篇文章中, 我们将上述的功能进行整合, 并且稍微修改一下界面. 便可以来完成我们的项目.
系统框图
程序主要使用了一个ESP32-S3-BOX-3 和 一个香橙派Zero3(MQTT服务), 和上位机服务(也可以部署在香橙派Zero3上). 首先, ESP32-S3采集音频数据,将音频数据通过POST请求将数据发送到FastAPI的文件上传接口, 然后由FastAPI调用阿里云的服务. 并且将转换结果和大模型的响应结果发送给MQTT队列, 然后ESP32-S3-BOX-3做出响应.
程序流程图
屏幕UI部分
之前我们的屏幕UI是一个键盘, 并不具备对应的行为. 我们可以在上面做一些简单的修改来适配当前的项目需求. 首先使用Squareline重新设计界面, 并且给按钮增加事件回掉函数.
这样当我们点击录音按钮的时候便会触发对应的录音功能, 然后点击send的时候便会把音频数据发送给大模型. 这个Send 按钮可以不需要的, 但是我这里为了效果更加直观点,就保留了(当用户点击send的时候会优先播放录制的音频数据, 然后再把音频数据进行发送)
对应的UI_event.c 如下所示
// This file was generated by SquareLine Studio // SquareLine Studio version: SquareLine Studio 1.5.1 // LVGL version: 8.3.6 // Project name: SquareLine_Project #include "ui.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "audio_play.h" #include "esp_log.h" #include "esp_http_client.h" #include "sys/stat.h" #include "esp_task_wdt.h" const char *TAG = "UI_EVENTS"; extern lv_obj_t *ui_status; void record_task(void *arg) { lv_label_set_text(ui_status, "Recording..."); record_file(REC_FILENAME); lv_label_set_text(ui_status, "Recording finished"); ESP_LOGI(TAG, "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); vTaskDelete(NULL); } void recordVioce(lv_event_t *e) { // 开始录音 ESP_LOGI("RECORD", "Recording started..."); xTaskCreatePinnedToCore(record_task, "record_task", 10240, NULL, 5, NULL, 0); } bool file_exists(const char *path) { struct stat st; return (stat(path, &st) == 0); } void send_audio_file(const char *url, const char *file_path) { if (!file_exists(file_path)) { ESP_LOGE("HTTP", "File does not exist: %s", file_path); return; } ESP_LOGI("HTTP", "Sending audio file to %s", url); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_POST, .timeout_ms = 30000, // 设置超时时间为 30 秒 }; esp_http_client_handle_t client = esp_http_client_init(&config); FILE *file = fopen(file_path, "rb"); if (file == NULL) { ESP_LOGE("HTTP", "Failed to open file: %s", file_path); esp_http_client_cleanup(client); return; } fseek(file, 0, SEEK_END); size_t file_size = ftell(file); fseek(file, 0, SEEK_SET); ESP_LOGI("HTTP", "File size: %d bytes", file_size); // 定义 boundary const char *boundary = "----ESP32Boundary"; char content_type[100]; snprintf(content_type, sizeof(content_type), "multipart/form-data; boundary=%s", boundary); esp_http_client_set_header(client, "Content-Type", content_type); // 计算请求体总大小 size_t header_size = strlen("--") + strlen(boundary) + strlen("\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n") + strlen("Content-Type: audio/wav\r\n\r\n") + strlen("\r\n--") + strlen(boundary) + strlen("--\r\n"); size_t total_size = header_size + file_size; esp_err_t err = esp_http_client_open(client, total_size); if (err != ESP_OK) { ESP_LOGE("HTTP", "Failed to open HTTP connection: %s", esp_err_to_name(err)); fclose(file); esp_http_client_cleanup(client); return; } // 写入 multipart/form-data 头部 char header[512]; int header_len = snprintf(header, sizeof(header), "--%s\r\n" "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n" "Content-Type: audio/wav\r\n\r\n", boundary); esp_http_client_write(client, header, header_len); // 写入文件内容 char buffer[4096]; size_t read_bytes; size_t total_bytes_sent = header_len; while ((read_bytes = fread(buffer, 1, sizeof(buffer), file)) > 0) { int bytes_written = esp_http_client_write(client, buffer, read_bytes); if (bytes_written < 0) { ESP_LOGE("HTTP", "Failed to write data to HTTP client"); fclose(file); esp_http_client_close(client); esp_http_client_cleanup(client); return; } total_bytes_sent += bytes_written; ESP_LOGI("HTTP", "Sent %d bytes, Total sent: %d bytes", bytes_written, total_bytes_sent); vTaskDelay(pdMS_TO_TICKS(10)); // 避免阻塞任务 } // 写入 multipart/form-data 尾部 char footer[128]; int footer_len = snprintf(footer, sizeof(footer), "\r\n--%s--\r\n", boundary); esp_http_client_write(client, footer, footer_len); total_bytes_sent += footer_len; fclose(file); err = esp_http_client_perform(client); if (err == ESP_OK) { int status_code = esp_http_client_get_status_code(client); int content_length = esp_http_client_get_content_length(client); ESP_LOGI("HTTP", "HTTP POST Status = %d, Content Length = %d", status_code, content_length); } else { ESP_LOGE("HTTP", "HTTP POST request failed: %s", esp_err_to_name(err)); } esp_http_client_close(client); esp_http_client_cleanup(client); } void sendRecord(lv_event_t *e) { lv_label_set_text(ui_status, "Sending..."); play_file(REC_FILENAME); send_audio_file("http://192.168.1.131:6000/upload", REC_FILENAME); lv_label_set_text(ui_status, "Sending finished"); }
上述代码主要就是两个核心行为即: 一、录制音频 二、发送音频.
消息回调部分
在消息回调的部分使用了MQTT来接收语音转换文字的内容和通义千问大模型的响应内容. 并且更新屏幕上的文本显示.
/** * MQTT 事件处理函数 * 该函数处理 MQTT 客户端的各种事件,如连接、断开连接、订阅、取消订阅、发布和数据接收等 * * @param handler_args 事件处理函数的参数,通常未使用 * @param base 事件基,标识事件来源 * @param event_id 事件标识符,表示具体的事件类型 * @param event_data 事件数据,包含事件的具体信息 */ static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { // 记录事件的基本信息 ESP_LOGD(TAG, "Event dispatched from event loop base=%s, event_id=%" PRIi32 "", base, event_id); // 将事件数据转换为 MQTT 事件处理句柄 esp_mqtt_event_handle_t event = event_data; // 获取 MQTT 客户端句柄 esp_mqtt_client_handle_t client = event->client; // 用于记录消息 ID 的变量 int msg_id; // 根据事件 ID 处理不同的 MQTT 事件 switch ((esp_mqtt_event_id_t)event_id) { case MQTT_EVENT_CONNECTED: // 当连接到 MQTT 代理时,订阅主题 "result" esp_mqtt_client_subscribe(client, "result", 0); ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED"); break; case MQTT_EVENT_DISCONNECTED: // 当与 MQTT 代理断开连接时,记录日志 ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED"); break; case MQTT_EVENT_SUBSCRIBED: // 当成功订阅主题时,记录日志和消息 ID ESP_LOGI(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event->msg_id); break; case MQTT_EVENT_UNSUBSCRIBED: // 当成功取消订阅主题时,记录日志和消息 ID ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event->msg_id); break; case MQTT_EVENT_PUBLISHED: // 当成功发布消息时,记录日志和消息 ID ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id); break; case MQTT_EVENT_DATA: // 当接收到订阅主题的数据时,处理数据 ESP_LOGI(TAG, "MQTT_EVENT_DATA"); // 使用 cJSON 解析接收到的数据 cJSON *root = cJSON_Parse(event->data); if (root != NULL) { cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content"); if (cJSON_IsString(content) && (content->valuestring != NULL)) { // 记录解析后的数据内容 ESP_LOGI(TAG, "content: %s", content->valuestring); // 更新数据到 ui_Screen1_Label4 lv_label_set_text(ui_Screen1_Label4, content->valuestring); } // 释放 cJSON 结构体 cJSON_Delete(root); } else { // 如果解析失败,记录警告日志 ESP_LOGW(TAG, "Failed to parse JSON data"); } break; case MQTT_EVENT_ERROR: // 当发生错误时,记录错误信息 ESP_LOGI(TAG, "MQTT_EVENT_ERROR"); if (event->error_handle->error_type == MQTT_ERROR_TYPE_TCP_TRANSPORT) { // 如果是 TCP 传输错误,记录详细的错误信息 log_error_if_nonzero("reported from esp-tls", event->error_handle->esp_tls_last_esp_err); log_error_if_nonzero("reported from tls stack", event->error_handle->esp_tls_stack_err); log_error_if_nonzero("captured as transport's socket errno", event->error_handle->esp_transport_sock_errno); ESP_LOGI(TAG, "Last errno string (%s)", strerror(event->error_handle->esp_transport_sock_errno)); } break; default: // 对于其他未处理的事件,记录事件 ID ESP_LOGI(TAG, "Other event id:%d", event->event_id); break; } }
其主要逻辑如上所示, 我已经增加了详细的注释, 即在MQTT连接的时候来订阅result的主题, 当接收到来自这个队列的消息的时候, 便更具消息内容来更新屏幕的显示.
更新内容的区域, 如上图橙色方框所示.
上位机代码
上位机代码使用的是Python的fastAPI框架进行搭建的, 其主要的内容就是接收来自ESP32S3-BOX的音频数据,然后保存到本地,并且调用阿里云的语音转文字API来拿到语音识别的文本内容,然后调用通义千问大模型的API接口从而生成消息的响应. 然后通过MQTT协议将响应的内容发送给ESP32-S3-BOX-3
from fastapi import FastAPI, File, UploadFile, HTTPException from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware import os import dashscope from dashscope.audio.asr import TranslationRecognizerRealtime, TranslationRecognizerCallback, TranscriptionResult, TranslationResult import paho.mqtt.client as mqtt import orjson import time from openai import OpenAI app = FastAPI() # 配置跨域支持 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) UPLOAD_FOLDER = '/tmp/uploads' if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) dashscope.api_key = "你的百炼大模型API 密钥" MQTT_BROKER = "192.168.1.113" MQTT_PORT = 1883 MQTT_USER = "MQTT账号" MQTT_PASSWORD = "MQTT密码" MQTT_TOPIC = "result" mqtt_client = mqtt.Client() mqtt_client.username_pw_set(MQTT_USER, MQTT_PASSWORD) def on_disconnect(client, userdata, rc): print("MQTT 断开连接,正在重连...") while True: try: client.reconnect() print("MQTT 重新连接成功!") break except Exception as e: print(f"MQTT 连接失败: {e},5 秒后重试...") time.sleep(5) mqtt_client.on_disconnect = on_disconnect mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60) mqtt_client.loop_start() class Callback(TranslationRecognizerCallback): def __init__(self): super().__init__() self.results = [] def on_event(self, request_id, transcription_result: TranscriptionResult, translation_result: TranslationResult, usage): result = {} if translation_result is not None: english_translation = translation_result.get_translation("en") result["sentence_id"] = english_translation.sentence_id result["translate_to_english"] = english_translation.text if transcription_result is not None: result["transcription_sentence_id"] = transcription_result.sentence_id result["transcription"] = transcription_result.text self.results.append(result) def perform_asr(file_path): callback = Callback() translator = TranslationRecognizerRealtime( model="gummy-realtime-v1", format="wav", sample_rate=16000, callback=callback) translator.start() try: with open(file_path, 'rb') as f: while (audio_data := f.read(12800)): translator.send_audio_frame(audio_data) finally: translator.stop() return callback.results def process_with_openai(text): client = OpenAI( api_key="你的百练大模型密钥", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", ) completion = client.chat.completions.create( model="qwen-plus", messages=[{'role': 'system', 'content': 'You are a helpful assistant.'}, {'role': 'user', 'content': text}] ) response_json = orjson.loads(completion.model_dump_json()) # 解析 JSON content = response_json.get("choices", [{}])[0].get("message", {}).get("content", "") # 提取 content return {"content": content} # 返回 JSON 格式的数据 @app.post("/upload") async def upload_file(file: UploadFile = File(...)): try: if not file: raise HTTPException(status_code=400, detail="No file received") file_path = os.path.join(UPLOAD_FOLDER, file.filename) with open(file_path, "wb") as f: content = await file.read() f.write(content) asr_results = perform_asr(file_path) if asr_results: last_result = asr_results[-1] mqtt_client.publish(MQTT_TOPIC, orjson.dumps(last_result)) openai_response = process_with_openai(last_result.get("transcription", "")) last_result["openai_response"] = openai_response mqtt_client.publish(MQTT_TOPIC, orjson.dumps(openai_response)) return JSONResponse(status_code=200, content={"message": "File uploaded and processed successfully", "results": asr_results}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/") async def root(): return {"message": "Hello World"}
上述的大多数代码都是来自阿里云百炼大模型的官方文档, 具体的调用细节可以参考百炼大模型控制台说明.
图片效果展示
默认开机画面,等待用户按下按钮,进行音频录制
录制界面, 当前的状态变换为recording
录制完成界面, 数据已经发送给后端服务.
数据响应界面: Give me short answer, where is Shanghai ? (简短回答, 上海在哪里?)
项目源码
使用教程: 将项目,放到esp-bsp的example目录下, 上位机安装对应依赖即可!
视频效果
https://www.bilibili.com/video/BV11BZaYJE4q (电子产品世界- 开发板试用活动-成果视频[ESP32S3-BOX-3])