随着人工智能和物联网技术的快速发展,对话机器人已成为智能家居、客服系统等领域的核心应用。本文将介绍如何利用 ESP32 微控制器、结合 Coze 大模型(用于对话生成)、百度千帆平台提供的自动语音识别(ASR)和文本转语音(TTS)服务,构建一个高效的对话机器人系统。该系统可实现语音输入识别、智能响应生成和语音输出功能,适用于低成本嵌入式设备。

1. 系统概述

对话机器人的核心流程包括三个主要阶段:

  • 语音输入处理:通过麦克风采集语音信号,使用 ASR 服务将语音转换为文本。
  • 对话生成:将文本输入到 Coze 大模型中,生成智能响应文本。
  • 语音输出:利用 TTS 服务将响应文本转换为语音信号,并通过扬声器输出。

整个系统基于 ESP32 实现硬件控制,借助百度千帆云服务处理计算密集型任务(如 ASR 和 TTS),而 Coze 大模型负责对话逻辑。这降低了 ESP32 的资源负担,使其在低功耗环境下高效运行。

2. 硬件组件

ESP32 是一款低成本、低功耗的 Wi-Fi 和蓝牙微控制器,适合物联网应用。本系统所需硬件包括:

  • ESP32 开发板:作为主控制器,处理数据通信和控制逻辑。
  • 麦克风模块:用于语音输入,常见模块如 INMP441(数字麦克风)或 MAX9814(模拟麦克风放大器),支持 ASR 输入。
  • 扬声器模块:用于语音输出,可通过 PWM 或 I2S 接口驱动。
  • 辅助电路:如电源管理、Wi-Fi 模块等。

其中,麦克风和扬声器模块的选择取决于具体应用场景。例如,MAX9814 提供高灵敏度,适合嘈杂环境;而 INMP441 则支持数字输出,简化 ESP32 接口。

3. 软件与服务集成

系统依赖于云服务处理 AI 任务,主要组件如下:

  • 百度千帆平台:提供强大的 ASR 和 TTS API。ASR 服务可将语音转换为文本,TTS 服务将文本转换为自然语音。百度千帆支持高精度识别和多种语言,适合嵌入式系统通过 HTTP 请求调用。
  • Coze 大模型:这是一个先进的对话生成模型,类似于大型语言模型(LLM),可用于生成上下文相关的响应。Coze 模型可通过 API 访问,输入文本后返回生成的对话内容。
  • ESP32 固件:使用 Arduino 框架或 MicroPython 开发,负责硬件控制、网络通信和数据转发。

集成时,ESP32 通过 Wi-Fi 连接到互联网,发送语音数据到百度千帆 ASR API,接收文本后转发到 Coze 模型,再将生成的文本发送到百度千帆 TTS API,最后播放语音。

4. 实现步骤

以下是构建系统的关键步骤,确保结构清晰且可复现:

步骤 1: 硬件设置

  • 连接麦克风模块到 ESP32 的 ADC 或 I2S 接口。
  • 连接扬声器模块到 ESP32 的 DAC 或 PWM 接口。
  • 配置 Wi-Fi 模块,确保 ESP32 可以访问互联网。

步骤 2: 云服务配置

  • 注册百度千帆账号,创建 ASR 和 TTS 应用,获取 API 密钥和端点 URL。
  • 设置 Coze 模型访问:如果 Coze 提供 API,注册并获取密钥;否则,可使用开源替代方案如 GPT 模型。
  • 编写 ESP32 代码处理 HTTP 请求。示例伪代码如下:

步骤 3: 优化与测试

  • 性能优化:ESP32 资源有限,建议使用流式处理(如分块发送音频数据),并设置超时处理。
  • 准确性测试:在安静环境中测试 ASR 识别率,调整麦克风增益;验证 Coze 模型的响应相关性。
  • 功耗管理:ESP32 进入低功耗模式当空闲时,延长电池寿命。
5. 应用场景与优势

本系统适用于:

  • 智能家居:作为语音助手控制灯光、温度等。
  • 教育机器人:提供互动学习体验。
  • 工业设备:实现语音控制指令。

优势包括:

  • 低成本:ESP32 硬件价格低廉,百度千帆提供免费层服务。
  • 高效性:云服务处理复杂 AI 任务,减少本地计算负担。
  • 可扩展性:易于集成其他传感器或服务。
6. 挑战与未来展望

当前挑战包括网络延迟(影响实时性)和隐私问题(语音数据上传)。未来可通过边缘计算优化,如使用本地轻量模型处理部分任务,或结合 5G 网络减少延迟。

总之,通过整合 ESP32、Coze 大模型和百度千帆 ASR/TTS,我们可以构建一个功能完善的对话机器人系统。开发者可基于上述框架进一步定制,推动智能嵌入式设备的发展。

相关代码:

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include "driver/i2s.h"
#include "FS.h"
#include "SD.h"
#include "SPI.h"
#include <base64.h>  // 需安装Base64库:搜索"Base64" by Daniel Pawlikowski

/************************ 核心配置(必须修改!)************************/
// WiFi配置
const char* WIFI_SSID = "你的WiFi名称";
const char* WIFI_PASS = "你的WiFi密码";

// Coze API配置
const char* COZE_API_KEY = "pat_DF8e73SOxxxxxxxxxx1VuKKxxxxxxaGwdBqc";
const char* COZE_BOT_ID = "757621xxxxxxx0";
const char* COZE_USER_ID = "123";  // 自定义固定用户ID
const char* COZE_API_DOMAIN = "api.coze.cn";
const int COZE_API_PORT = 443;

// 百度ASR/TTS配置(替换为你的密钥)
const char* BAIDU_API_KEY = "你的百度API Key";
const char* BAIDU_SECRET_KEY = "你的百度Secret Key";
const char* BAIDU_ASR_URL = "https://vop.baidu.com/pro_api";  // 极速版API
const char* BAIDU_TTS_URL = "https://tsn.baidu.com/text2audio";

/************************ 硬件引脚定义 ************************/
// INMP441 录音I2S引脚(I2S_NUM_0)
#define I2S_REC_BCLK 26
#define I2S_REC_LRC  25
#define I2S_REC_DIN  34

// MAX98357A 播放I2S引脚(I2S_NUM_1,与录音区分)
#define I2S_PLAY_BCLK 13
#define I2S_PLAY_LRC  12
#define I2S_PLAY_DOUT 14

// SD卡SPI引脚
#define SD_CS    5
#define SD_SCK   18
#define SD_MISO  19
#define SD_MOSI  23

/************************ 全局配置 ************************/
// 音频参数(与ASR/TTS要求一致)
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE I2S_BITS_PER_SAMPLE_16BIT
#define BYTES_PER_SAMPLE (BITS_PER_SAMPLE / 8)
#define RECORD_DURATION 6000  // 6秒录音
#define RECORD_FILE_PATH "/recording.raw"  // 录音文件(raw格式)
#define TTS_FILE_PATH "/tts.mp3"  // TTS缓存文件

// 状态机(控制流程顺序)
typedef enum {
  STATE_IDLE,        // 空闲
  STATE_RECORDING,   // 录音中
  STATE_ASR,         // 百度ASR识别中
  STATE_COZE,        // Coze AI对话中
  STATE_TTS,         // 百度TTS合成中
  STATE_PLAYING      // 语音播放中
} DeviceState;
DeviceState currentState = STATE_IDLE;

// 全局变量
WiFiClientSecure client;  // ESP32 HTTPS客户端
String accessToken = "";  // 百度API Token(有效期30天)
unsigned long tokenExpireTime = 0;  // Token过期时间戳
String asrText = "";      // ASR识别结果文本
String cozeReply = "";    // Coze AI回复文本

/************************ 工具函数 ************************/
// 打印带时间戳的日志
void logPrintln(String msg) {
  Serial.printf("[%lu] %s\n", millis(), msg.c_str());
}

// 检查WiFi连接(断线重连)
bool checkWiFi() {
  if (WiFi.status() != WL_CONNECTED) {
    logPrintln("WiFi断线,正在重连...");
    WiFi.reconnect();
    int retry = 0;
    while (WiFi.status() != WL_CONNECTED && retry < 10) {
      delay(500);
      retry++;
    }
    if (WiFi.status() == WL_CONNECTED) {
      logPrintln("WiFi重连成功!IP:" + WiFi.localIP().toString());
      return true;
    } else {
      logPrintln("WiFi重连失败");
      return false;
    }
  }
  return true;
}

// URL编码函数(修复错误2:urlEncode未定义)
String urlEncode(String str) {
  String encodedString = "";
  char c;
  char code0;
  char code1;
  char code2;
  for (int i = 0; i < str.length(); i++) {
    c = str.charAt(i);
    if (c == ' ') {
      encodedString += '+';
    } else if (isalnum(c)) {
      encodedString += c;
    } else {
      code1 = (c & 0xf0) >> 4;
      code2 = (c & 0x0f);
      code0 = 0x25;
      encodedString += code0;
      encodedString += (code1 < 10) ? (char)(code1 + 48) : (char)(code1 + 55);
      encodedString += (code2 < 10) ? (char)(code2 + 48) : (char)(code2 + 55);
    }
    delayMicroseconds(1);
  }
  return encodedString;
}

// 获取文件大小(修复错误1和错误5:SD.size()不存在)
uint64_t getFileSize(String filePath) {
  if (!SD.exists(filePath)) {
    return 0;
  }
  File file = SD.open(filePath, FILE_READ);
  uint64_t size = file.size();
  file.close();
  return size;
}

/************************ SD卡初始化 ************************/
bool initSDCard() {
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS)) {
    logPrintln("❌ SD卡挂载失败!");
    return false;
  }

  uint8_t cardType = SD.cardType();
  if (cardType == CARD_NONE) {
    logPrintln("❌ 未检测到SD卡!");
    return false;
  }

  logPrintln("✅ SD卡类型:" + String(cardType == CARD_MMC ? "MMC" : (cardType == CARD_SD ? "SDSC" : (cardType == CARD_SDHC ? "SDHC" : "未知"))));
  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  uint64_t freeSpace = (SD.totalBytes() - SD.usedBytes()) / (1024 * 1024);
  logPrintln("✅ SD卡总容量:" + String(cardSize) + " MB");
  logPrintln("✅ SD卡剩余空间:" + String(freeSpace) + " MB");
  return true;
}

/************************ I2S录音初始化 ************************/
void initI2SRecord() {
  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = BITS_PER_SAMPLE,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_I2S_MSB,
    .intr_alloc_flags = 0,
    .dma_buf_count = 2,
    .dma_buf_len = 64,
    .use_apll = false
  };

  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_REC_BCLK,
    .ws_io_num = I2S_REC_LRC,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_REC_DIN
  };
  i2s_set_pin(I2S_NUM_0, &pin_config);
  logPrintln("✅ I2S录音模块初始化完成");
}

/************************ I2S播放初始化 ************************/
void initI2SPlay() {
  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = 16000,  // TTS默认16kHz
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_I2S_MSB,
    .intr_alloc_flags = 0,
    .dma_buf_count = 4,
    .dma_buf_len = 1024,
    .use_apll = false
  };

  i2s_driver_install(I2S_NUM_1, &i2s_config, 0, NULL);
  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_PLAY_BCLK,
    .ws_io_num = I2S_PLAY_LRC,
    .data_out_num = I2S_PLAY_DOUT,
    .data_in_num = I2S_PIN_NO_CHANGE
  };
  i2s_set_pin(I2S_NUM_1, &pin_config);
  i2s_stop(I2S_NUM_1);
  logPrintln("✅ I2S播放模块初始化完成");
}

/************************ 录音功能 ************************/
void startRecording() {
  if (currentState != STATE_IDLE) {
    logPrintln("❌ 当前非空闲状态,无法录音!");
    return;
  }
  if (!checkWiFi()) return;

  currentState = STATE_RECORDING;
  unsigned long recordStartMillis = millis();

  // 删除旧录音文件
  if (SD.exists(RECORD_FILE_PATH)) {
    SD.remove(RECORD_FILE_PATH);
    logPrintln("ℹ️ 删除旧录音文件");
  }

  // 初始化I2S录音
  initI2SRecord();

  // 打开文件写入
  File recFile = SD.open(RECORD_FILE_PATH, FILE_WRITE);
  if (!recFile) {
    logPrintln("❌ 打开录音文件失败!");
    i2s_driver_uninstall(I2S_NUM_0);
    currentState = STATE_IDLE;
    return;
  }

  logPrintln("📢 开始录音(6秒后自动结束)...");
  int16_t sampleBuffer[64];
  while (currentState == STATE_RECORDING && (millis() - recordStartMillis) < RECORD_DURATION) {
    size_t bytesRead;
    i2s_read(I2S_NUM_0, sampleBuffer, sizeof(sampleBuffer), &bytesRead, portMAX_DELAY);
    if (bytesRead > 0) {
      recFile.write((uint8_t*)sampleBuffer, bytesRead);
    }
    delay(1);
  }

  // 清理资源
  recFile.close();
  i2s_driver_uninstall(I2S_NUM_0);
  currentState = STATE_IDLE;
  logPrintln("🛑 录音结束");

  // 检查录音文件
  if (SD.exists(RECORD_FILE_PATH)) {
    uint64_t fileSize = getFileSize(RECORD_FILE_PATH);
    float duration = (float)fileSize / (SAMPLE_RATE * BYTES_PER_SAMPLE);
    logPrintln("✅ 录音文件保存成功!大小:" + String(fileSize) + " 字节,时长:" + String(duration, 2) + "秒");
    
    // 录音完成后自动触发ASR识别
    currentState = STATE_ASR;
  } else {
    logPrintln("❌ 录音文件保存失败!");
  }
}

/************************ 百度API Token获取 ************************/
bool getBaiduToken() {
  // 检查Token是否有效(提前10分钟刷新)
  if (accessToken.length() > 0 && millis() < tokenExpireTime - 600000) {
    return true;
  }

  logPrintln("ℹ️ 获取百度API Token...");
  String tokenUrl = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + String(BAIDU_API_KEY) + "&client_secret=" + String(BAIDU_SECRET_KEY);
  
  if (client.connect("aip.baidubce.com", 443)) {
    client.print("GET " + tokenUrl + " HTTP/1.1\r\n");
    client.print("Host: aip.baidubce.com\r\n");
    client.print("Connection: close\r\n\r\n");

    String response = "";
    while (client.connected() || client.available()) {
      if (client.available()) {
        response += client.readString();
      }
    }
    client.stop();

    // 解析JSON
    int jsonStart = response.indexOf("{");
    if (jsonStart != -1) {
      String jsonStr = response.substring(jsonStart);
      DynamicJsonDocument doc(1024);
      DeserializationError error = deserializeJson(doc, jsonStr);
      if (!error && doc.containsKey("access_token")) {
        accessToken = doc["access_token"].as<String>();
        long expireSeconds = doc["expires_in"].as<long>();
        tokenExpireTime = millis() + expireSeconds * 1000;
        logPrintln("✅ Token获取成功,有效期:" + String(expireSeconds / 3600) + "小时");
        return true;
      } else {
        logPrintln("❌ Token解析失败:" + String(error.c_str()));
        logPrintln("响应:" + jsonStr);
      }
    }
  }
  logPrintln("❌ Token获取失败");
  return false;
}

/************************ 百度ASR识别(极速版)************************/
void baiduASR() {
  if (currentState != STATE_ASR) return;
  logPrintln("🔊 开始ASR识别...");
  asrText = "";

  // 检查Token和录音文件
  if (!getBaiduToken() || !SD.exists(RECORD_FILE_PATH)) {
    currentState = STATE_IDLE;
    return;
  }

  File recFile = SD.open(RECORD_FILE_PATH, FILE_READ);
  if (!recFile) {
    logPrintln("❌ 打开录音文件失败!");
    currentState = STATE_IDLE;
    return;
  }

  // 构造ASR请求参数(分块编码,避免内存溢出)
  String requestUrl = BAIDU_ASR_URL + String("?access_token=") + accessToken;
  String headers = "Host: vop.baidu.com\r\n";
  headers += "Content-Type: application/json\r\n";
  headers += "Connection: close\r\n";

  // 读取文件并Base64编码(分块处理)
  const size_t chunkSize = 4096;  // 分块大小(4KB)
  uint8_t chunk[chunkSize];
  String speechBase64 = "";

  while (recFile.available() > 0) {
    size_t bytesRead = recFile.read(chunk, chunkSize);
    speechBase64 += base64::encode(chunk, bytesRead);
  }
  recFile.close();

  // 构造JSON请求体(修复错误1:替换SD.size为getFileSize)
  DynamicJsonDocument reqDoc(4096);
  reqDoc["format"] = "raw";
  reqDoc["rate"] = SAMPLE_RATE;
  reqDoc["dev_pid"] = 1537;  // 中文普通话
  reqDoc["speech"] = speechBase64;
  reqDoc["cuid"] = WiFi.macAddress();
  reqDoc["len"] = getFileSize(RECORD_FILE_PATH);  // 修复:使用自定义getFileSize函数

  String postBody;
  serializeJson(reqDoc, postBody);

  // 发送HTTPS请求
  if (client.connect("vop.baidu.com", 443)) {
    client.print("POST " + requestUrl + " HTTP/1.1\r\n");
    client.print(headers);
    client.print("Content-Length: " + String(postBody.length()) + "\r\n\r\n");
    client.print(postBody);

    String response = "";
    while (client.connected() || client.available()) {
      if (client.available()) {
        response += client.readString();
      }
    }
    client.stop();

    // 解析响应
    int jsonStart = response.indexOf("{");
    if (jsonStart != -1) {
      String jsonStr = response.substring(jsonStart);
      DynamicJsonDocument resDoc(1024);
      DeserializationError error = deserializeJson(resDoc, jsonStr);
      if (!error && resDoc["err_no"].as<int>() == 0) {
        asrText = resDoc["result"][0].as<String>();
        logPrintln("✅ ASR识别成功:" + asrText);
        
        // ASR完成后自动触发Coze对话
        currentState = STATE_COZE;
      } else {
        logPrintln("❌ ASR识别失败:err_no=" + String(resDoc["err_no"].as<int>()) + ", err_msg=" + resDoc["err_msg"].as<String>());
        currentState = STATE_IDLE;
      }
    } else {
      logPrintln("❌ ASR响应无JSON");
      currentState = STATE_IDLE;
    }
  } else {
    logPrintln("❌ 连接ASR服务器失败");
    currentState = STATE_IDLE;
  }
}

/************************ Coze AI对话 ************************/
String processCozeAnswer(DynamicJsonDocument& resDoc) {
  if (resDoc["code"].as<int>() != 0) {
    return "❌ Coze错误:" + resDoc["msg"].as<String>();
  }

  JsonArray data = resDoc["data"].as<JsonArray>();
  String reply = "无回复";
  for (auto item : data) {
    if (item["type"].as<String>() == "answer") {
      reply = item["content"].as<String>();
      break;
    }
  }
  return reply;
}

String getCozeChatResult(String conversationId, String chatId) {
  String retrieveUrl = "/v3/chat/retrieve?conversation_id=" + conversationId + "&chat_id=" + chatId;
  String msgListUrl = "/v3/chat/message/list?chat_id=" + chatId + "&conversation_id=" + conversationId + "&bot_id=" + String(COZE_BOT_ID) + "&task_id=" + chatId;

  int maxRetries = 20;
  for (int retry = 0; retry < maxRetries; retry++) {
    logPrintln("🤔 Coze轮询中(" + String(retry+1) + "/" + String(maxRetries) + ")");

    // 查询状态
    if (client.connect(COZE_API_DOMAIN, COZE_API_PORT)) {
      client.print("GET " + retrieveUrl + " HTTP/1.1\r\n");
      client.print("Host: " + String(COZE_API_DOMAIN) + "\r\n");
      client.print("Authorization: Bearer " + String(COZE_API_KEY) + "\r\n");
      client.print("Connection: close\r\n\r\n");

      String retrieveResp = "";
      while (client.connected() || client.available()) {
        if (client.available()) retrieveResp += client.readString();
      }
      client.stop();

      int jsonStart = retrieveResp.indexOf("{");
      if (jsonStart != -1) {
        String jsonStr = retrieveResp.substring(jsonStart);
        DynamicJsonDocument resDoc(1024);
        DeserializationError error = deserializeJson(resDoc, jsonStr);
        if (!error && resDoc["code"].as<int>() == 0) {
          String status = resDoc["data"]["status"].as<String>();
          if (status == "completed") {
            // 获取消息列表
            if (client.connect(COZE_API_DOMAIN, COZE_API_PORT)) {
              client.print("GET " + msgListUrl + " HTTP/1.1\r\n");
              client.print("Host: " + String(COZE_API_DOMAIN) + "\r\n");
              client.print("Authorization: Bearer " + String(COZE_API_KEY) + "\r\n");
              client.print("Connection: close\r\n\r\n");

              String msgResp = "";
              while (client.connected() || client.available()) {
                if (client.available()) msgResp += client.readString();
              }
              client.stop();

              int msgJsonStart = msgResp.indexOf("{");
              if (msgJsonStart != -1) {
                String msgJsonStr = msgResp.substring(msgJsonStart);
                DynamicJsonDocument msgDoc(2048);
                DeserializationError msgError = deserializeJson(msgDoc, msgJsonStr);
                if (!msgError) {
                  return processCozeAnswer(msgDoc);
                }
              }
            }
          } else if (status == "failed") {
            return "❌ Coze任务失败:" + resDoc["data"]["error_msg"].as<String>();
          }
        }
      }
    }
    delay(1000);
  }
  return "❌ Coze轮询超时";
}

void callCozeAI() {
  if (currentState != STATE_COZE || asrText.length() == 0) return;
  logPrintln("🤖 调用Coze AI:" + asrText);
  cozeReply = "";

  if (!checkWiFi()) {
    currentState = STATE_IDLE;
    return;
  }

  // 构造Coze请求体
  DynamicJsonDocument reqDoc(1024);
  reqDoc["bot_id"] = COZE_BOT_ID;
  reqDoc["user_id"] = COZE_USER_ID;
  reqDoc["stream"] = false;
  reqDoc["auto_save_history"] = true;

  JsonArray messages = reqDoc.createNestedArray("additional_messages");
  JsonObject userMsg = messages.createNestedObject();
  userMsg["role"] = "user";
  userMsg["content"] = asrText;
  userMsg["content_type"] = "text";

  String postBody;
  serializeJson(reqDoc, postBody);

  // 发送Coze请求
  if (client.connect(COZE_API_DOMAIN, COZE_API_PORT)) {
    client.print("POST /v3/chat HTTP/1.1\r\n");
    client.print("Host: " + String(COZE_API_DOMAIN) + "\r\n");
    client.print("Authorization: Bearer " + String(COZE_API_KEY) + "\r\n");
    client.print("Content-Type: application/json\r\n");
    client.print("Content-Length: " + String(postBody.length()) + "\r\n");
    client.print("Connection: close\r\n\r\n");
    client.print(postBody);

    String response = "";
    while (client.connected() || client.available()) {
      if (client.available()) response += client.readString();
    }
    client.stop();

    // 解析对话ID
    int jsonStart = response.indexOf("{");
    if (jsonStart != -1) {
      String jsonStr = response.substring(jsonStart);
      DynamicJsonDocument resDoc(1024);
      DeserializationError error = deserializeJson(resDoc, jsonStr);
      if (!error && resDoc["code"].as<int>() == 0) {
        String chatId = resDoc["data"]["id"].as<String>();
        String conversationId = resDoc["data"]["conversation_id"].as<String>();
        logPrintln("✅ Coze对话创建成功:" + chatId);

        // 轮询获取结果
        cozeReply = getCozeChatResult(conversationId, chatId);
        logPrintln("✅ Coze回复:" + cozeReply);

        // Coze完成后自动触发TTS
        currentState = STATE_TTS;
      } else {
        logPrintln("❌ Coze响应解析失败:" + String(error.c_str()));
        currentState = STATE_IDLE;
      }
    } else {
      logPrintln("❌ Coze响应无JSON");
      currentState = STATE_IDLE;
    }
  } else {
    logPrintln("❌ 连接Coze失败");
    currentState = STATE_IDLE;
  }
}

/************************ 百度TTS合成+I2S播放 ************************/
void baiduTTSAndPlay() {
  if (currentState != STATE_TTS || cozeReply.length() == 0) return;
  logPrintln("🎤 开始TTS合成:" + cozeReply);

  // 检查Token
  if (!getBaiduToken()) {
    currentState = STATE_IDLE;
    return;
  }

  // 构造TTS请求参数(修复错误2:使用自定义urlEncode;修复错误3:String拼接)
  String encodedText = urlEncode(cozeReply);
  String ttsParams = "tex=" + encodedText + 
                     "&lan=zh&cuid=" + WiFi.macAddress() + 
                     "&ctp=1&tok=" + accessToken + 
                     "&spd=5&pit=5&vol=15&per=0";
  String requestUrl = String(BAIDU_TTS_URL) + "?" + ttsParams;  // 修复:转换为String再拼接

  // 下载TTS音频到SD卡
  if (SD.exists(TTS_FILE_PATH)) {
    SD.remove(TTS_FILE_PATH);
  }

  File ttsFile = SD.open(TTS_FILE_PATH, FILE_WRITE);
  if (!ttsFile) {
    logPrintln("❌ 打开TTS文件失败!");
    currentState = STATE_IDLE;
    return;
  }

  // 发送TTS请求并保存音频
  if (client.connect("tsn.baidu.com", 443)) {
    client.print("GET " + requestUrl + " HTTP/1.1\r\n");
    client.print("Host: tsn.baidu.com\r\n");
    client.print("Connection: close\r\n\r\n");

    // 跳过HTTP头部,只保存音频数据
    bool headerEnd = false;
    while (client.connected() || client.available()) {
      if (client.available()) {
        String line = client.readStringUntil('\n');
        if (headerEnd) {
          // 修复错误4:强制类型转换const char* → const uint8_t*
          ttsFile.write((const uint8_t*)line.c_str(), line.length());
        }
        if (line == "\r") {
          headerEnd = true;  // 头部结束标志
        }
      }
    }
    client.stop();
    ttsFile.close();

    // 播放MP3文件(使用I2S)(修复错误5:替换SD.size为getFileSize)
    uint64_t ttsFileSize = getFileSize(TTS_FILE_PATH);
    if (SD.exists(TTS_FILE_PATH) && ttsFileSize > 100) {
      logPrintln("🎵 开始播放TTS语音(大小:" + String(ttsFileSize) + "字节)...");
      currentState = STATE_PLAYING;

      File playFile = SD.open(TTS_FILE_PATH, FILE_READ);
      if (playFile) {
        i2s_start(I2S_NUM_1);
        size_t bytesRead;
        uint8_t playBuffer[1024];
        while (playFile.available() > 0 && currentState == STATE_PLAYING) {
          bytesRead = playFile.read(playBuffer, sizeof(playBuffer));
          i2s_write(I2S_NUM_1, playBuffer, bytesRead, &bytesRead, portMAX_DELAY);
        }
        playFile.close();
        i2s_stop(I2S_NUM_1);
      }

      logPrintln("🎵 TTS播放完成");
      currentState = STATE_IDLE;
    } else {
      logPrintln("❌ TTS音频文件无效(大小:" + String(ttsFileSize) + "字节)!");
      currentState = STATE_IDLE;
    }
  } else {
    logPrintln("❌ 连接TTS服务器失败");
    ttsFile.close();
    currentState = STATE_IDLE;
  }
}

/************************ 串口指令解析 ************************/
void parseSerialCommand() {
  if (Serial.available() > 0) {
    String input = Serial.readStringUntil('\n');
    input.trim();
    if (input.length() == 0) return;

    logPrintln("🗣️  串口输入:" + input);
    if (input == "1") {
      // 指令1:开始录音(触发语音对话流程)
      startRecording();
    } else if (input == "3") {
      // 指令3:查询录音信息
      if (SD.exists(RECORD_FILE_PATH)) {
        uint64_t size = getFileSize(RECORD_FILE_PATH);
        logPrintln("📋 录音文件信息:大小=" + String(size) + "字节,时长=" + String((float)size/(SAMPLE_RATE*BYTES_PER_SAMPLE),2) + "秒");
      } else {
        logPrintln("📋 无录音文件");
      }
    } else if (input == "q") {
      // 指令q:退出
      logPrintln("❌ 退出程序");
      while (1);
    } else {
      // 其他输入:作为文本对话
      if (currentState == STATE_IDLE) {
        asrText = input;  // 直接复用文本到Coze流程
        currentState = STATE_COZE;
      } else {
        logPrintln("❌ 当前忙碌中,无法处理文本对话!");
      }
    }
  }
}

/************************ 初始化 ************************/
void setup() {
  Serial.begin(115200);
  delay(1000);
  logPrintln("=====================================");
  logPrintln("    ESP32 语音AI对话机器人    ");
  logPrintln("=====================================");
  logPrintln("📋 支持指令:");
  logPrintln("   1 - 开始语音对话(录音6秒→ASR→AI→TTS)");
  logPrintln("   3 - 查询录音文件信息");
  logPrintln("   q - 退出程序");
  logPrintln("   其他文本 - 直接文本对话");
  logPrintln("=====================================\n");

  // 初始化硬件
  if (!initSDCard()) {
    while (1) {
      logPrintln("❌ SD卡初始化失败,程序暂停!");
      delay(1000);
    }
  }
  initI2SPlay();  // 初始化播放模块(录音模块按需初始化)

  // 连接WiFi
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  logPrintln("连接WiFi:" + String(WIFI_SSID));
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  logPrintln("\n✅ WiFi连接成功!IP:" + WiFi.localIP().toString());

  // ESP32 HTTPS关闭证书验证(简化开发)
  client.setInsecure();

  // 初始化百度Token
  getBaiduToken();

  currentState = STATE_IDLE;
  logPrintln("✅ 系统初始化完成,等待指令...");
}

/************************ 主循环 ************************/
void loop() {
  // 优先处理串口指令
  parseSerialCommand();

  // 状态机驱动流程
  switch (currentState) {
    case STATE_ASR:
      baiduASR();
      break;
    case STATE_COZE:
      callCozeAI();
      break;
    case STATE_TTS:
      baiduTTSAndPlay();
      break;
    default:
      // 空闲状态,do nothing
      break;
  }

  delay(100);
}

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐