以下方案演示了如何基于 ESP32-S3,通过私有化大模型组合 ASR(语音识别)、LLM(语言大模型)和 TTS(语音合成)来构建一个语音交互系统,并且通过 WebSocket 保持与服务器的长连接通讯。整体方案分为以下几个部分:
- 系统整体架构与数据流
- 协议设计与消息格式
- 服务器端实现示例
- ESP32-S3 端实现示例
- 运行流程与示例
下面将对各部分进行详细说明。
ESP32-S3没想到私有化大模型速度也能这么快
ESP32-S3 psram 2M,flash 4M 实时唤醒,打断
1. 系统整体架构与数据流
1.1 硬件概览
-
ESP32-S3 开发板
- 集成麦克风(或者外接 I2S 麦克风)用于录音输入
- 集成喇叭(或者外接 I2S 喇叭/耳机)用于播放合成语音
- Wi-Fi 模组用于与服务器进行网络通信
-
服务器(云端或本地私有部署均可)
- 包含以下核心功能模块:
- ASR 模块:接收音频数据,实时或分段进行语音识别,返回文本结果
- LLM 模块:根据识别出的文本,通过私有化大模型进行上下文理解与回答生成
- TTS 模块:将大模型的回答转换为语音数据
- 提供 WebSocket 服务端接口,与 ESP32-S3 进行长连接通讯
- 包含以下核心功能模块:
1.2 整体数据流示意
- ESP32-S3 采集到用户语音,通过 WebSocket 将音频数据流发送给 服务器。
- 服务器 ASR 模块 对音频进行实时识别,识别结果以文本的形式返回给 ESP32-S3。
- 当用户语音输入结束后(或达到一定停顿判定),服务器的 LLM 模块 进行语义理解和回答生成,返回文本回答。
- 服务器使用 TTS 模块 将回答文本转成语音流,通过 WebSocket 发送给 ESP32-S3。
- ESP32-S3 接收音频流并播放给用户。
2. 协议设计与消息格式
2.1 WebSocket 连接与握手
- ESP32-S3 侧作为客户端,连接到服务器的 WebSocket 端口。例如:
ws://<server_ip_or_domain>:<port>/speech
- 握手成功后,服务器端和 ESP32-S3 端保持长连接,可以实时双向发送消息。
2.2 消息类型与数据格式
可将每个 WebSocket 消息用一个 JSON 包装,方便在前后端解析。示例结构如下:
{
"msg_type": "<消息类型>",
"seq_id": "<序列号>",
"payload": {
// 具体内容
}
}
常见的消息类型包括:
audio_chunk
:ESP32-S3 发送的音频分片payload
包含音频的原始字节或者 Base64 编码
asr_result
:服务器返回的阶段性或最终识别文本payload
包含识别文本、置信度等
asr_end
:语音识别结束的指令- 表示服务器不再等待更多的音频输入
llm_request
:当服务器端需要进行 LLM 推理时,从 ASR 模块转发/触发给 LLM 模块llm_response
:服务器返回的 LLM 生成文本payload
包含大模型生成的文本
tts_chunk
:服务器返回的语音合成音频分片payload
包含合成后的音频数据(比如 PCM 或某种压缩格式)
tts_end
:语音合成结束的指令
2.3 数据传输细节
-
音频格式
- 采样率:16kHz / 16bit / 单声道 或其他与 ASR 模型兼容的配置
- 传输方式:PCM 原始数据或使用 Opus 编码后传输(减少带宽)
-
文本格式
- 统一使用 UTF-8 编码
- JSON 传输时需做相应的转义
-
消息序列号(
seq_id
)- 用于追踪同一对话、同一次请求的所有消息关系
- 例如:一个完整的语音请求从语音片段开始到语音合成结束,都可以使用相同的
seq_id
3. 服务器端实现示例
这里以 Python 为例,使用了常见的 websockets
库搭建 WebSocket 服务,并且示例性地说明如何组合 ASR、LLM 和 TTS 模块。
此示例代码框架仅用于演示,实际可根据需要嵌入第三方或者私有化大模型相关的推理调用。
3.1 Python WebSocket 服务器示例
import asyncio
import websockets
import base64
import uuid
import json
# 伪代码,指示如何调用ASR/LLM/TTS,实际可用第三方或自研模块替换
from asr_module import asr_process
from llm_module import llm_infer
from tts_module import tts_synthesis
PORT = 8000
# 维护一个对话状态类,例如存储临时音频数据和识别结果
class SessionState:
def __init__(self):
self.audio_chunks = []
self.asr_text = ""
async def handle_connection(websocket, path):
print(f"[Server] New connection from {websocket.remote_address}")
session_state = SessionState()
seq_id = None
try:
async for message in websocket:
# 解析 JSON 消息
data = json.loads(message)
msg_type = data.get("msg_type")
seq_id = data.get("seq_id")
payload = data.get("payload", {})
if msg_type == "audio_chunk":
# 收到音频分片(PCM 或 Base64 之后的音频)
audio_data_b64 = payload.get("audio_data")
if audio_data_b64:
audio_data = base64.b64decode(audio_data_b64)
session_state.audio_chunks.append(audio_data)
# 这里也可以进行实时流式 ASR
asr_partial_result = asr_process(audio_data)
# 将分段识别结果返回 ESP32-S3
resp = {
"msg_type": "asr_result",
"seq_id": seq_id,
"payload": {
"text": asr_partial_result,
"final": False
}
}
await websocket.send(json.dumps(resp))
elif msg_type == "asr_end":
# 用户不再发送音频,进行完整 ASR 处理
final_text = asr_process(b"".join(session_state.audio_chunks), finalize=True)
session_state.asr_text = final_text
# 返回完整识别结果
resp = {
"msg_type": "asr_result",
"seq_id": seq_id,
"payload": {
"text": final_text,
"final": True
}
}
await websocket.send(json.dumps(resp))
# 调用 LLM 进行文本生成
llm_answer = llm_infer(final_text)
resp_llm = {
"msg_type": "llm_response",
"seq_id": seq_id,
"payload": {
"text": llm_answer
}
}
await websocket.send(json.dumps(resp_llm))
# 调用 TTS 进行合成
for tts_chunk in tts_synthesis(llm_answer):
# tts_chunk 为一次 PCM 或其他音频格式的分片
tts_b64 = base64.b64encode(tts_chunk).decode('utf-8')
resp_tts = {
"msg_type": "tts_chunk",
"seq_id": seq_id,
"payload": {
"audio_data": tts_b64
}
}
await websocket.send(json.dumps(resp_tts))
# 合成结束通知
resp_tts_end = {
"msg_type": "tts_end",
"seq_id": seq_id,
"payload": {}
}
await websocket.send(json.dumps(resp_tts_end))
else:
# 其他消息类型的处理
pass
except websockets.ConnectionClosed as e:
print(f"[Server] Connection closed: {e}")
finally:
print(f"[Server] Client {websocket.remote_address} disconnected.")
async def main():
async with websockets.serve(handle_connection, "0.0.0.0", PORT):
print(f"[Server] WebSocket server started on port {PORT}")
await asyncio.Future() # run forever
if __name__ == "__main__":
asyncio.run(main())
说明
asr_process
:演示用函数,实际可调用私有化部署的语音识别引擎(如 Kaldi/WeNet/Whisper 等)。llm_infer
:演示用函数,实际可调用私有化部署的大模型推理接口(如 fine-tuned GPT、LLaMA、ChatGLM 等)。tts_synthesis
:演示用函数,实际可调用私有化部署的 TTS 引擎(如 Tacotron、VITS、Fastspeech、讯飞离线 SDK 等)。- 这里将音频分段(
audio_chunk
)和识别结束(asr_end
)分开处理,可以根据需要调整为流式 ASR 或一次性发送音频。 - 服务器将识别结果、LLM 输出和 TTS 输出通过同一个 WebSocket 连接发送回 ESP32-S3。
4. ESP32-S3 端实现示例
以下以 ESP-IDF + C/C++ 为例子,示范如何使用 WebSocket 客户端进行长连接,并与服务器进行音频和文本的收发。
4.1 主要组件
- I2S 驱动:录制麦克风音频并回放合成音频
- WebSocket 客户端:与服务器建立长连接,发送 JSON 消息和音频数据
- 事件回调:处理服务器返回的识别结果、TTS 音频并播放
4.2 代码示例
CMakeLists.txt(简化示例)
cmake_minimum_required(VERSION 3.5)
set(EXTRA_COMPONENT_DIRS
$ENV{IDF_PATH}/examples/common_components/protocol_examples_common
# 如果有 websocket 客户端组件,需要包含进来
)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32_s3_asr_llm_tts)
main.cpp(或 main.c)
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "protocol_examples_common.h"
// 如果使用了 ESP-IDF 的 websocket 或者第三方库
#include "esp_websocket_client.h"
#include "cJSON.h"
#include "driver/i2s.h"
#include "base64.h"
static const char *TAG = "ASR_LLM_TTS";
// WebSocket 相关
static esp_websocket_client_handle_t client = NULL;
static bool connected = false;
// 音频相关设置
#define SAMPLE_RATE 16000
#define I2S_CHANNEL_NUM (1)
#define I2S_DMA_BUF_LEN (1024)
static void i2s_init()
{
// 初始化 I2S,用于麦克风录音和播放
// 注意 ESP32-S3 I2S 引脚、模式配置
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, // 单声道
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0,
.dma_buf_count = 4,
.dma_buf_len = I2S_DMA_BUF_LEN,
.use_apll = false,
.tx_desc_auto_clear = true,
};
// 配置 i2s pin
i2s_pin_config_t pin_config = {
// 根据开发板原理图填写
.bck_io_num = CONFIG_I2S_BCK_PIN,
.ws_io_num = CONFIG_I2S_WS_PIN,
.data_out_num = CONFIG_I2S_DO_PIN,
.data_in_num = CONFIG_I2S_DI_PIN
};
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pin_config);
}
// WebSocket 事件回调
static void websocket_event_handler(void *handler_args, esp_event_base_t base,
int32_t event_id, void *event_data)
{
esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data;
switch (event_id) {
case WEBSOCKET_EVENT_CONNECTED:
ESP_LOGI(TAG, "WebSocket connected");
connected = true;
break;
case WEBSOCKET_EVENT_DISCONNECTED:
ESP_LOGI(TAG, "WebSocket disconnected");
connected = false;
break;
case WEBSOCKET_EVENT_DATA:
// 收到服务器消息
if (data->op_code == 1) { // TEXT Frame
// 解析 JSON
char *msg = strndup((char*)data->data_ptr, data->data_len);
cJSON *root = cJSON_Parse(msg);
if (root) {
cJSON *msg_type = cJSON_GetObjectItem(root, "msg_type");
cJSON *seq_id = cJSON_GetObjectItem(root, "seq_id");
cJSON *payload = cJSON_GetObjectItem(root, "payload");
if (msg_type && payload) {
if (strcmp(msg_type->valuestring, "asr_result") == 0) {
cJSON *textItem = cJSON_GetObjectItem(payload, "text");
ESP_LOGI(TAG, "ASR result: %s", textItem->valuestring);
// 如果 final=true,说明识别结束
}
else if (strcmp(msg_type->valuestring, "llm_response") == 0) {
cJSON *textItem = cJSON_GetObjectItem(payload, "text");
ESP_LOGI(TAG, "LLM answer: %s", textItem->valuestring);
}
else if (strcmp(msg_type->valuestring, "tts_chunk") == 0) {
// Base64 音频数据
cJSON *audioData = cJSON_GetObjectItem(payload, "audio_data");
if (audioData) {
size_t out_len = 0;
unsigned char *decoded_data = base64_decode((const unsigned char*)audioData->valuestring,
strlen(audioData->valuestring),
&out_len);
// 播放音频
size_t bytes_written = 0;
i2s_write(I2S_NUM_0, decoded_data, out_len, &bytes_written, portMAX_DELAY);
free(decoded_data);
}
}
else if (strcmp(msg_type->valuestring, "tts_end") == 0) {
ESP_LOGI(TAG, "TTS playback end.");
}
}
cJSON_Delete(root);
}
free(msg);
}
break;
default:
break;
}
}
static void websocket_app_start(void)
{
esp_websocket_client_config_t ws_cfg = {};
ws_cfg.uri = CONFIG_WEBSOCKET_URI; // e.g.: "ws://192.168.1.100:8000/speech"
client = esp_websocket_client_init(&ws_cfg);
esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, websocket_event_handler, NULL);
esp_websocket_client_start(client);
}
static void record_and_send_task(void *arg)
{
while (1) {
if (connected) {
// 从 I2S 读一段音频
uint8_t i2s_buf[I2S_DMA_BUF_LEN];
size_t bytes_read;
i2s_read(I2S_NUM_0, i2s_buf, I2S_DMA_BUF_LEN, &bytes_read, portMAX_DELAY);
// 将这段音频进行 base64 编码并通过 JSON 发出去
size_t b64_len = 0;
unsigned char *b64_data = base64_encode(i2s_buf, bytes_read, &b64_len);
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "msg_type", "audio_chunk");
cJSON_AddStringToObject(root, "seq_id", "session_123");
cJSON *payload = cJSON_CreateObject();
cJSON_AddStringToObject(payload, "audio_data", (char*)b64_data);
cJSON_AddItemToObject(root, "payload", payload);
char *out_str = cJSON_PrintUnformatted(root);
esp_websocket_client_send_text(client, out_str, strlen(out_str), portMAX_DELAY);
free(out_str);
free(b64_data);
cJSON_Delete(root);
} else {
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
}
static void stop_recording_and_send_asr_end()
{
// 当检测到语音结束(或按键)时,发送 asr_end
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "msg_type", "asr_end");
cJSON_AddStringToObject(root, "seq_id", "session_123");
cJSON_AddItemToObject(root, "payload", cJSON_CreateObject());
char *out_str = cJSON_PrintUnformatted(root);
esp_websocket_client_send_text(client, out_str, strlen(out_str), portMAX_DELAY);
free(out_str);
cJSON_Delete(root);
}
extern "C" void app_main(void)
{
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// 连接 Wi-Fi
ESP_ERROR_CHECK(example_connect());
// 初始化 I2S
i2s_init();
// 启动 WebSocket 客户端
websocket_app_start();
// 创建录音任务
xTaskCreate(record_and_send_task, "record_and_send_task", 4096, NULL, 5, NULL);
// 在实际应用中,可以进行 VAD 或按键判断结束语音,然后调用 stop_recording_and_send_asr_end()
while (true) {
vTaskDelay(pdMS_TO_TICKS(1000));
// 这里只是演示可以在合适的时机结束语音
// stop_recording_and_send_asr_end();
}
}
说明
record_and_send_task
循环从 I2S 读取音频数据,然后通过 WebSocket 发送给服务器。stop_recording_and_send_asr_end()
用于在合适时机(例如检测到语音结束、或按键事件)通知服务器进行最终识别和后续的 LLM + TTS 流程。- 收到服务器端的
asr_result
、llm_response
、tts_chunk
等消息后分别进行处理,最后在tts_chunk
中拿到音频数据即可播放。
5. 运行流程与示例
-
上电/启动
- ESP32-S3 启动后连接 Wi-Fi
- 建立 WebSocket 连接
-
开始录音
- 用户对着麦克风说话,ESP32-S3 不断采集音频,并将音频分片发送到服务器
-
实时/流式 ASR
- 服务器接收音频分片,进行阶段性 ASR(可选)
- 服务器发送
asr_result
(final=false
)到 ESP32-S3,展示或调试语音实时识别
-
结束语音输入
- 当检测到语音结束或按键触发时,ESP32-S3 发送
asr_end
- 服务器进行整段音频的最终识别
- 服务器发送
asr_result
(final=true
)
- 当检测到语音结束或按键触发时,ESP32-S3 发送
-
LLM 推理
- 服务器将最终识别的文本丢给私有化大模型进行理解与生成回答
- 服务器通过
llm_response
将回答返回给 ESP32-S3
-
TTS 合成与播放
- 服务器在得到 LLM 输出后进行语音合成(可分段输出),用
tts_chunk
发送给 ESP32-S3 - ESP32-S3 收到后通过 I2S 播放
- 当合成结束,服务器发送
tts_end
- 服务器在得到 LLM 输出后进行语音合成(可分段输出),用
-
重复对话
- 用户继续输入语音,ESP32-S3 继续发送音频,实现多轮交互
- 可以使用相同的
seq_id
标识同一次对话,也可由服务器来管理对话状态
总结
以上方案演示了一个比较完整的端到端流程,包括:
-
ESP32-S3 端:
- 采集音频、编码发送、播放服务器下行的 TTS 音频
- 使用 WebSocket 保持长连接,并解析服务器返回的识别结果、LLM 文本和合成音频
-
服务器端:
- 搭建 WebSocket 服务
- 接收音频、调用 ASR 模块做语音识别
- 将识别文本通过私有化大模型进行语义理解和回答生成
- 将回答文本通过 TTS 模块合成为音频,再分段发送给 ESP32-S3 播放
-
协议与数据格式:
- 通过 JSON 包装消息,含
msg_type
、seq_id
、payload
- 音频数据用 Base64 编码或其他可行的方式传输
- 通过
asr_result
、llm_response
、tts_chunk
、tts_end
等进行交互控制
- 通过 JSON 包装消息,含
此架构可以根据项目需求做进一步的扩展与优化,例如:
- ASR 使用流式识别,减少延迟
- LLM 结合上下文管理,实现多轮对话
- TTS 使用更高效的压缩或更高保真音频
- 在私有服务器中部署多个大模型服务,通过负载均衡应对并发
通过以上思路,便可以在 ESP32-S3 上实现基于语音输入 -> ASR -> LLM -> TTS 输出的闭环对话系统。