以下方案演示了如何基于 ESP32-S3,通过私有化大模型组合 ASR(语音识别)、LLM(语言大模型)和 TTS(语音合成)来构建一个语音交互系统,并且通过 WebSocket 保持与服务器的长连接通讯。整体方案分为以下几个部分:
下面将对各部分进行详细说明。
ESP32-S3没想到私有化大模型速度也能这么快
ESP32-S3 psram 2M,flash 4M 实时唤醒,打断
ESP32-S3 开发板
服务器(云端或本地私有部署均可)
ws://:/speech
可将每个 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
:语音合成结束的指令音频格式
文本格式
消息序列号(seq_id
)
seq_id
这里以 Python 为例,使用了常见的 websockets
库搭建 WebSocket 服务,并且示例性地说明如何组合 ASR、LLM 和 TTS 模块。
此示例代码框架仅用于演示,实际可根据需要嵌入第三方或者私有化大模型相关的推理调用。
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 或一次性发送音频。以下以 ESP-IDF + C/C++ 为例子,示范如何使用 WebSocket 客户端进行长连接,并与服务器进行音频和文本的收发。
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
#include
#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
中拿到音频数据即可播放。上电/启动
开始录音
实时/流式 ASR
asr_result
(final=false
)到 ESP32-S3,展示或调试语音实时识别结束语音输入
asr_end
asr_result
(final=true
)LLM 推理
llm_response
将回答返回给 ESP32-S3TTS 合成与播放
tts_chunk
发送给 ESP32-S3tts_end
重复对话
seq_id
标识同一次对话,也可由服务器来管理对话状态以上方案演示了一个比较完整的端到端流程,包括:
ESP32-S3 端:
服务器端:
协议与数据格式:
msg_type
、seq_id
、payload
asr_result
、llm_response
、tts_chunk
、tts_end
等进行交互控制此架构可以根据项目需求做进一步的扩展与优化,例如:
通过以上思路,便可以在 ESP32-S3 上实现基于语音输入 -> ASR -> LLM -> TTS 输出的闭环对话系统。