news 2026/9/19 10:26:02

ESP32接入百度智能云语音识别:从硬件到API完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32接入百度智能云语音识别:从硬件到API完整指南

说实话,第一次把 ESP32 和百度智能云的语音识别接到一起时,我最大的感受是:硬件端并不难,真正花时间的全是“音频格式对不对”“请求头带没带对”“token 有没有过期”这类细节。这篇东西我会把整条链路完整讲一遍,从选开发板、焊麦克风,到配 Arduino 环境、烧录,再到云端建应用、写代码调 API,尽量按照我实际踩坑的顺序来,你照着走基本能少走两三天弯路。

这项目解决的是个很典型的物联网需求:设备端算力不够,跑不动本地语音识别模型,那就把音频传到云端,由云端识别成文字再返回。ESP32 做音频采集和联网控制,百度智能云负责“听懂人话”,两边一配合,一个能听懂指令的智能硬件就有了雏形。适不适合你参考?只要你有 ESP32 开发基础,或者至少烧录过 Arduino 程序,后面跟着做都不算难。要是纯新手,建议先把 GPIO、串口、WiFi 连接这几个基础点过一遍。

1. 项目整体的设计与方案选型

1.1 为什么是 ESP32 而不是手机、电脑或树莓派

先聊选型。语音识别这事儿,手机和电脑当然能做得更好,但作为物联网项目,它们的体积、功耗、成本都不合适。ESP32 的优势在哪?一颗芯片集成 WiFi 和蓝牙,价钱便宜,官方支持 Arduino、ESP-IDF、MicroPython 三套开发方式,GPIO、I2S、ADC、SPI 这些外设也全,基本属于“一块板子什么都能试”的典型代表。

如果你对比过 STM32,会觉得两者路线差别挺大。STM32 的实时性和稳定性更强,适合做电机控制、精密仪表这类硬实时场景,但网络接入你得外挂 ESP8266 或 W5500 模块。ESP32 则是“我自带联网能力”,做语音识别这种必然要上云的场景,天然省掉一层通信模块的折腾。

树莓派我也试过,性能确实强,本地装 Vosk 都能跑,但成本和功耗不是一个量级。一个树莓派零头能买两三块 ESP32 开发板,而且树莓派跑 Linux 系统,开机时间长,做小批量硬件不划算。综合来看,ESP32 是这种“采集音频-联网-调云端 API”场景里性价比最高的选择。

1.2 为什么把语音识别放云端,而不是端侧跑模型

有朋友会问:语音识别能不能全部在 ESP32 上做?我的答案是,简单关键词勉强可以,复杂识别基本不现实。ESP32 是双核 240MHz 的 MCU,内存通常是 320KB SRAM 加几 MB PSRAM,跑个几十 MB 的语音模型非常吃力,识别准确率和响应速度都跟不上。

云端的逻辑不同。百度的语音识别模型部署在服务器集群上,模型规模和算力都是端侧没法比的,普通话、方言、中英文混合识别效果都很稳定。你只需要把音频按指定格式传上去,等一两秒就能拿回文字结果。这种“端侧采集、云端计算”的模式,正好是物联网行业做 AI 功能的主流做法。

当然代价也有:设备必须联网,识别过程有网络延迟,而且调用次数受免费额度限制。所以这项目适合做“智能音箱、语音控制面板、老人呼叫器”这类对实时性要求不极端、网络环境稳定的场景。如果你的需求是离线关键词唤醒,那应该去研究 ESP32-S3 加 MicroWakeWord 的方案,或者直接用带离线识别能力的语音模块。

1.3 数据链路拆解:从声波到文本结果

整条链路其实不复杂,我用大白话拆一下:

  1. 麦克风模块(比如 INMP441)把声音转成数字信号,通过 I2S 总线送给 ESP32。
  2. ESP32 把音频数据整理成 16kHz、16bit、单声道的 PCM 裸流,暂存在内存里。
  3. 录音结束,ESP32 把这段 PCM 数据作为 HTTP POST 的 body,发送给百度智能云的语音识别接口。
  4. 云端解析音频,跑语音识别模型,返回一个 JSON,里面包含识别出的文字。
  5. ESP32 拿到文字后用 ArduinoJson 解析出来,再根据内容执行动作(比如开灯、播报、发通知)。

这里最关键的是第 2 步和第 3 步。很多项目失败就是因为音频格式不对,或者请求格式不符合接口要求。百度智能云支持的音频格式有 PCM、WAV、AMR 等,但 ESP32 直接录音生成的就是原始 PCM,这是最高效也最省内存的方式。采样率必须统一为 16000,位深 16bit,声道数是单声道,这三个参数任何一个不对,服务端都会报参数错误。

1.4 为什么音频格式选 16kHz / 16bit / 单声道

你要是不理解这几个参数的含义,后面调试会非常痛苦。采样率 16kHz 表示每秒采样 16000 个点,也就是能还原 8kHz 以内的声音频率,而人说话的语音能量集中在 300Hz 到 3400Hz,所以 16kHz 对语音识别来说完全够用。8kHz 虽然也能传,但识别准确率会下降。

位深 16bit 表示每个采样点用 16 位二进制存,动态范围更大,能记录更细腻的音量变化。单声道就不用解释了,一个麦克风录出来的本来就是单声道。这三个参数定下来,一分钟的音频体积是 16000×2×60 ≈ 1.92MB,API 对单次请求有限制(60 秒内),所以录音时长最好控制在 10 秒以内,既满足大多数语音指令,也能让内存和上传压力小很多。

还记得网络热词里有人问“ESP32 蓝牙和 WiFi 可以一起用吗”,这项目主要走 WiFi,如果你想让手机蓝牙配置 WiFi 信息,是可以共存的,两者共享同一根天线,分时复用。我会在后面提到这个用法,但核心数据传输还是走 WiFi。

2. 硬件准备与基础开发环境配置

2.1 开发板选型:经典版还是 S3

ESP32 家族现在型号很多,做这个项目我建议两种选择。第一种是经典的 ESP32-WROOM-32 开发板,也就是最常见的 30 脚或 38 脚板子,价格便宜、资料最多、网上案例基本都能直接抄。第二种是 ESP32-S3 开发板,S3 的 AI 加速指令对神经网络推理更友好,但如果只是调用云端 API 其实用不上,S3 的 I2S 引脚编号和经典版不一样,代码得改。

我自己的主力板是 ESP32-DevKitC V4,用的 ESP32-WROOM-32E 模组,4MB Flash。买的时候注意别买到 Flash 只有 1MB 的板子,Arduino 编译出来的固件加上分区表经常超过 1MB,烧录会失败。另外选板子尽量挑带 USB 转串口芯片的型号,常见的是 CP2102 或 CH340,两者驱动不同,但都成熟稳定。

如果你手头有 ESP32-C3、ESP32-S2 这类单核板也能做,但要注意 C3 没有 I2S 外设(新版用其他方式模拟),硬件库支持会麻烦一些,新手不建议用 C3 做这个项目。ESP32 系列里、带“双核 + I2S 硬件外设”的板子优先级最高。

2.2 麦克风模块选择:INMP441 对比 MAX4466

麦克风是整个项目里最容易忽略却又最关键的一环。我强烈推荐用 INMP441,这是一颗 I2S 数字输出的 MEMS 麦克风模块,直接输出数字信号,抗干扰能力强,不需要额外的 ADC 电路。接线就四根线:VDD、GND、SCK、WS、SD(这里算五根,L/R 接地),非常干净。

MAX4466 是另一种常见选择,它输出的是模拟电压信号,需要接 ESP32 的 ADC 引脚。ADC 方式的问题是 ESP32 的 ADC 线性度一般、噪声偏大,采集到的语音质量很难保证,识别准确率会受影响。我一开始偷懒用 MAX4466 试过,识别率感人,换 INMP441 之后立刻正常了。

INMP441 的 L/R 引脚要接 GND,这样它会在 WS 信号为低电平时输出数据,对应 I2S 的左声道。如果你的代码设置的是 RIGHT 声道,那就得把 L/R 接 VDD。这块接反了不会有物理损坏,但读到的数据全是 0,排查时容易懵。另外 INMP441 的工作电压是 3.3V,一定不要接到 5V,会烧模块。

2.3 接线与电源注意事项

INMP441 与经典 ESP32 开发板的接线我直接给出来,你照着插就行:

INMP441 引脚ESP32 引脚
VDD3.3V
GNDGND
SCKGPIO14
WSGPIO15
SDGPIO32
L/RGND

这几个引脚在 Arduino-ESP32 core 里是默认可用的 I2S 引脚,不需要特别映射。SCK 是位时钟,WS 是声道选择,SD 是数据输出。如果用的是 ESP32-S3,建议改成 GPIO4、GPIO5、GPIO6 这类常用引脚,并在代码里用I2S_PIN_NO_CHANGE或显式指定。

供电方面,INMP441 和 ESP32 用同一个 3.3V 电源没问题,但最好别直接从电脑 USB 口拖太多外设。如果你后续还要接功放、喇叭、继电器,建议用 5V/2A 以上的电源适配器,通过开发板的 5V 引脚接入,然后由板载稳压芯片转 3.3V 给各个模块。注意,如果外接设备电流较大,不要从开发板 3.3V 引脚取电,会触发过热保护。

面包板 + 杜邦线做原型测试是可以的,接线尽量短一些,音频信号线太长容易引入噪声。焊接的话,INMP441 底部有两个焊盘容易连锡,用助焊剂和烙铁温度控制在 350 摄氏度左右,快速操作基本没风险。

2.4 搭建 Arduino 开发环境并烧录首个测试程序

开发环境我推荐先用 Arduino IDE,原因无它:生态成熟,示例多,调试门槛低。到 Arduino IDE 的“开发板管理器”里添加 ESP32 支持,步骤是:

  1. 打开 Arduino IDE,进入“文件”->“首选项”,在“附加开发板管理器网址”里填入https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  2. 打开“工具”->“开发板”->“开发板管理器”,搜索 esp32,安装 Espressif ESP32 官方支持包。
  3. 选择开发板型号为 ESP32 Dev Module,端口选成你板子对应的 COM 口。
  4. 写一个最简单的 Blink 程序,验证工具链没问题。

烧录时有个常见坑:很多开发板需要手动进入下载模式。操作方法是按住板上的 BOOT 按钮,然后点击“上传”,等串口开始输出“Connecting...”,再松开 BOOT 按钮。如果板子带自动下载电路(比如很多 ESP32 DevKit 都有),就不需要手动按键,插上 USB 直接上传即可。要是上传一直卡在Connecting..._____.....,八成就是 BOOT 时序没对上,多试两次或者换根数据线(有些 MicroUSB 线只能充电不能传数据)。

开发环境这步顺利的话,下面就可以进入云端配置了。我在这个项目里用的是 Arduino-ESP32 core 2.0.x 版本,I2S 代码兼容性最好。如果你下载的是 core 3.x,I2S 的 API 改过,部分旧示例会报错,到时候要留意适配。

3. 百度智能云语音识别服务开通与密钥管理

3.1 创建应用并开通语音识别能力

百度智能云的控制台改版过几次,但大致流程稳定。你先注册并登录百度智能云账号,完成实名认证,然后在控制台搜索“语音技术”,进入语音技术总览页面,点击“创建应用”。应用创建时要填应用名称、类型、描述,用途默认选“语音识别”即可。创建完成之后,控制台会给你一组 API Key 和 Secret Key,这组密钥是你调用所有百度 AI 接口的通行证。

拿到密钥后还要在“语音识别”服务页面开通对应能力。百度语音识别有短语音识别、实时语音识别、录音文件识别等多个产品,本项目用的是“短语音识别”,接口地址是https://vop.baidu.com/server_api。开通后页面会显示免费额度,短语音识别按次计费,新用户会有一定免费调用次数,具体以你控制台显示的额度为准,超额后按官网价格计费。

这里有个小提醒:API Key 和 Secret Key 不要随便贴到 GitHub 公开仓库,也不要在群里发截图。密钥泄露后别人可以拿你的额度去调用接口,造成费用损失。建议在代码里用一个单独的头文件存储密钥,并且本地编译时不要提交到版本管理。

3.2 理解 API Key、Secret Key 与 Access Token 的关系

很多新手第一次看百度云文档会被三个“Key”搞晕。我打个比方:API Key 是你的用户名,Secret Key 是你的密码,Access Token 是临时通行证。你不能直接拿用户名密码去调用每个接口,而是先登录(请求 token 接口)换一张带有效期的通行证,然后每次调用语音识别都出示这张通行证。

获取 Access Token 的接口是:

GET https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=你的APIKey&client_secret=你的SecretKey

返回的 JSON 里包含access_tokenexpires_in等字段。expires_in默认是 2592000 秒,也就是 30 天。这个 token 在有效期内可以反复使用,所以没必要每次开机都去申请。

实操中我建议把 token 缓存到 ESP32 的 NVS(非易失存储)里,存两样东西:token 字符串和获取时的时间戳。启动时先读 NVS,如果 token 存在且没有过期,直接用它调语音识别;如果不存在或过期了,再重新获取并更新 NVS。这样做的好处是设备重启后可以立刻使用,不用每次等 DNS 解析和 HTTP 握手,体验会好很多。

3.3 免费额度与日常使用注意事项

百度智能云的新用户免费额度每个阶段都可能调整,我在文章里不写死具体数字,以免误导。你登录控制台后,在“语音技术”概览页能直接看到剩余量。我个人的使用习惯是,开发调试阶段把录音时长控制在 3 到 5 秒,这样每天测试几十次也不会轻松把免费额度耗尽。

另外一个容易忽略的问题:百度智能云的短语音识别接口是 HTTPS。ESP32 发起 HTTPS 请求需要配置 TLS 证书或跳过证书校验。开发调试阶段,我直接用WiFiClientSecuresetInsecure()跳过证书校验,省心省力。如果做产品化,建议把服务端证书固定到固件里,避免中间人攻击。别嫌产品化麻烦,语音指令涉及隐私,安全这关必须过,具体做法后面代码部分会说。

最后,如果你对“API 密钥权限”这个概念不熟,我再补充一句:Secret Key 不能直接出现在前端或客户端代码里,因为它相当于管理员密码。哪怕是嵌入式设备,密钥也要做混淆或加密存储,最理想的方式是通过你自己的后端服务代理转发请求,设备只和你自己的服务器通信。个人项目图省事可以本地存,但心里要有这个安全意识。

4. 音频采集与格式处理:最容易翻车的一环

4.1 I2S 采集的基本原理

I2S 是一种数字音频总线协议,常见于音频 ADC/DAC 芯片与主控之间的数据交换。它有三根关键信号线:SCK(位时钟,也叫 BCLK)、WS(声道选择,也叫 LRCK)和 SD(串行数据)。SCK 每个时钟周期传输一个 bit,WS 用来区分左右声道,SD 上按位传输采样值。

INMP441 作为从设备,接收 ESP32 主机产生的 SCK 和 WS 信号,再把麦克风采集到的声音数据通过 SD 引脚发送出去。ESP32 侧配置好 I2S 外设后,底层 DMA 会自动把数据搬运到内存缓冲区,你不用死等每个字节,这对长时间录音特别重要。

用生活类比:I2S 就像一条流水线,SCK 是传送带的节拍,WS 是区分产品批次(左声道/右声道)的标签,SD 是传送带上流动的货物。你只需要在流水线末端定时取货,不需要干预传送带本身。

4.2 INMP441 采样代码与参数设置

在 Arduino-ESP32 core 2.x 里,I2S 采集代码可以写成这样:

#include <driver/i2s.h> #define I2S_WS 15 #define I2S_SCK 14 #define I2S_SD 32 void i2s_init() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .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 = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 1024, .use_apll = false, .tx_desc_auto_clear = false, .fixed_mclk = 0 }; i2s_pin_config_t pin_config = { .bck_io_num = I2S_SCK, .ws_io_num = I2S_WS, .data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_SD }; i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_0, &pin_config); }

几个参数的重点:sample_rate必须等于 16000,bits_per_sample必须是 16,channel_format设置成ONLY_LEFT是因为 INMP441 的 L/R 引脚接了 GND,数据只在左声道输出。dma_buf_countdma_buf_len共同决定 DMA 缓冲大小,8×1024 字节大概 8KB,足够应付 16kHz 音频流的临时存储。

如果你用的是 core 3.x,i2s_config_t结构体和安装接口有变化(比如新增了clk_srcmclk_multiple等字段),编译报错时去查对应版本的迁移文档。

4.3 音频数据的缓冲、拼接与内存管理

录音过程需要把 I2S 读到的数据不断追加到一个大缓冲区里。ESP32 的 SRAM 有限,经典版可用的堆内存也就 200KB 左右,所以录音时间不能无限长。我的做法是定义一个全局数组,比如uint8_t audio_buffer[16000 * 2 * 5];,也就是 5 秒、16kHz、16bit 单声道的 PCM 数据,共 160KB。这个大小在 ESP32 上可以安全分配。

录制时用一个标志位控制开始和停止。我习惯用 GPIO0(板载 BOOT 按键)作为录音触发:按下开始录音,松开结束录音并自动上传识别。GPIO0 在运行时需要加上拉电阻,Arduino 里可以这样初始化:

pinMode(0, INPUT_PULLUP);

循环里读取按键状态,检测到低电平就进入录音状态,持续从 I2S 读数据填充 buffer,同时计算已录字节数。为了避免按键抖动,建议加一个 50ms 的消抖逻辑。我初期没做消抖,结果是按一下触发两次录音,浪费了不少 API 调用次数。

内存方面还有一个细节:如果你用 ArduinoJson 去解析大 JSON 返回,它需要额外的堆内存。所以录音 buffer 不要一次性贪大,5 秒足够大多数指令识别。识别完记得立刻释放动态内存,否则跑几次之后堆碎片化严重,设备会莫名重启。

4.4 信号质量问题与排查

录音质量直接影响识别准确率,这是所有语音识别项目绕不开的坎。我踩过的坑主要有三类:

一是电源噪声。用电脑 USB 供电时,开关电源的高频纹波会窜进麦克风,采集到的音频底噪很大。解决办法是改用充电宝或独立电源供电,或者给 INMP441 的 VDD 加一个 10uF 和 0.1uF 电容并联去耦。

二是环境噪声。如果你在电脑风扇旁边测试,识别率会明显下降。代码层面可以加一个简单的能量检测,只有声音幅度超过阈值才开始正式录音,这样可以避免把静音段传给云端浪费流量。百度开放平台还提供“极速版”和“标准版”模型,标准版对噪声鲁棒性更好。

三是接线松动。杜邦线接触不良会导致 I2S 数据丢帧,音频听起来“卡顿”,识别结果自然是乱的。我排查时用逻辑分析仪看 SCK/WS/SD 波形,或者直接在代码里打印读到的数据量,如果音量明显异常,就检查 SD 引脚是不是虚接。

5. 调用百度智能云语音识别 API 的完整实现

5.1 获取 Access Token 的流程与代码

先把获取 token 的函数写出来,我会用 WiFiClientSecure 做 HTTPS 请求:

#include <WiFi.h> #include <WiFiClientSecure.h> #include <ArduinoJson.h> const char* apiKey = "你的APIKey"; const char* secretKey = "你的SecretKey"; String getAccessToken() { WiFiClientSecure client; client.setInsecure(); String url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + String(apiKey) + "&client_secret=" + String(secretKey); if (!client.connect("aip.baidubce.com", 443)) { Serial.println("token 连接失败"); return ""; } client.print(String("GET ") + url + " HTTP/1.1\r\n" + "Host: aip.baidubce.com\r\n" + "Connection: close\r\n\r\n"); String response = ""; while (client.connected() || client.available()) { if (client.available()) { response += client.readString(); } } int jsonStart = response.indexOf('{'); if (jsonStart == -1) return ""; String jsonBody = response.substring(jsonStart); DynamicJsonDocument doc(1024); deserializeJson(doc, jsonBody); String token = doc["access_token"] | ""; return token; }

这个函数会丢掉 HTTP 响应头,只保留 JSON 部分。setInsecure()是跳过证书校验的意思,开发期没问题。如果你有强迫症,可以把百度服务器的根证书烧进去,这样握手会校验证书链,安全性更高,但代码量会多不少,而且证书过期后要重新更新固件。

5.2 语音识别请求的拼装细节

获取 token 后调用语音识别接口,这一步是全文的重点。接口地址是:

https://vop.baidu.com/server_api?dev_pid=1537

dev_pid是模型参数,1537 表示普通话搜索模型,适合短句和指令识别。如果你需要识别英文,可以改成 1737;需要方言,比如四川话、粤语,也有对应的 pid,具体看官方文档。

请求方式为 POST,请求体直接放原始 PCM 数据,不要包 JSON,不要做 base64。请求头需要设置:

Content-Type: audio/pcm; rate=16000

注意这里的rate=16000必须和实际采样率一致。请求头里还要带两个参数:Host: vop.baidu.com,以及请求 URL 上的access_token参数。我一开始漏掉了Content-Type头,服务端统一报3300参数错误,排查了好一阵才发现是请求头的问题。

请求体长度就是 PCM 数据的字节数,HTTP 层用Content-Length声明。如果你用的是client.print()直接输出二进制数据,要小心字符串和二进制混用导致的数据损坏,我建议用client.write()发送 PCM 数据。

5.3 完整的 Arduino 示例代码

下面给一个可直接标编译的完整示例,按 BOOT 键录音,松手识别,结果通过串口打印:

#include <WiFi.h> #include <WiFiClientSecure.h> #include <ArduinoJson.h> #include <driver/i2s.h> const char* ssid = "你的WiFi名"; const char* password = "你的WiFi密码"; const char* apiKey = "你的APIKey"; const char* secretKey = "你的SecretKey"; #define I2S_WS 15 #define I2S_SCK 14 #define I2S_SD 32 #define RECORD_BUTTON 0 #define SAMPLE_RATE 16000 #define RECORD_SECONDS 5 #define BUFFER_SIZE (SAMPLE_RATE * 2 * RECORD_SECONDS) uint8_t audio_buffer[BUFFER_SIZE]; volatile bool recording = false; size_t recorded_bytes = 0; void i2s_init() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | 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 = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 1024, .use_apll = false, .tx_desc_auto_clear = false, .fixed_mclk = 0 }; i2s_pin_config_t pin_config = { .bck_io_num = I2S_SCK, .ws_io_num = I2S_WS, .data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_SD }; i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_0, &pin_config); } String getAccessToken() { WiFiClientSecure client; client.setInsecure(); String url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + String(apiKey) + "&client_secret=" + String(secretKey); if (!client.connect("aip.baidubce.com", 443)) { Serial.println("token 连接失败"); return ""; } client.print(String("GET ") + url + " HTTP/1.1\r\n" + "Host: aip.baidubce.com\r\n" + "Connection: close\r\n\r\n"); String response = ""; while (client.connected() || client.available()) { if (client.available()) { response += client.readString(); } } int jsonStart = response.indexOf('{'); if (jsonStart == -1) return ""; String jsonBody = response.substring(jsonStart); DynamicJsonDocument doc(1024); deserializeJson(doc, jsonBody); return doc["access_token"] | ""; } String recognizeAudio(uint8_t* data, size_t len, String token) { WiFiClientSecure client; client.setInsecure(); if (!client.connect("vop.baidu.com", 443)) { return "连接识别接口失败"; } String url = "https://vop.baidu.com/server_api?dev_pid=1537&access_token=" + token; client.print(String("POST ") + url + " HTTP/1.1\r\n" + "Host: vop.baidu.com\r\n" + "Content-Type: audio/pcm; rate=16000\r\n" + "Content-Length: " + len + "\r\n" + "Connection: close\r\n\r\n"); client.write(data, len); String response = ""; while (client.connected() || client.available()) { if (client.available()) { response += client.readString(); } } int jsonStart = response.indexOf('{'); if (jsonStart == -1) return "响应中没有JSON"; String jsonBody = response.substring(jsonStart); DynamicJsonDocument doc(2048); if (deserializeJson(doc, jsonBody)) return "JSON解析失败"; int err_no = doc["err_no"] | -1; if (err_no != 0) { String err_msg = doc["err_msg"] | "未知错误"; return "错误 " + String(err_no) + ": " + err_msg; } const char* text = doc["result"][0] | ""; return String(text); } void setup() { Serial.begin(115200); pinMode(RECORD_BUTTON, INPUT_PULLUP); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi 连接成功"); i2s_init(); Serial.println("按住 BOOT 键说话,松手识别"); } void loop() { static bool lastState = HIGH; bool btnState = digitalRead(RECORD_BUTTON); if (lastState == HIGH && btnState == LOW) { delay(50); if (digitalRead(RECORD_BUTTON) == LOW) { recorded_bytes = 0; recording = true; Serial.println("开始录音..."); } } if (recording) { size_t bytes_read = 0; i2s_read(I2S_NUM_0, audio_buffer + recorded_bytes, BUFFER_SIZE - recorded_bytes, &bytes_read, portMAX_DELAY); recorded_bytes += bytes_read; if (recorded_bytes >= BUFFER_SIZE) { recording = false; Serial.println("缓冲区已满,停止录音"); } } if (lastState == LOW && btnState == HIGH && recorded_bytes > 0) { delay(50); if (digitalRead(RECORD_BUTTON) == HIGH) { recording = false; Serial.printf("录音结束,共 %d 字节,开始识别...\n", recorded_bytes); String token = getAccessToken(); if (token.length() == 0) { Serial.println("获取token失败"); } else { String result = recognizeAudio(audio_buffer, recorded_bytes, token); Serial.println("识别结果: " + result); } recorded_bytes = 0; } } lastState = btnState; delay(10); }

代码逻辑很直白:主循环里检测 BOOT 按键的下降沿开始录音,上升沿结束并调用识别。i2s_read是阻塞读取,但音频数据持续到达,实际不会卡死。要注意BUFFER_SIZE - recorded_bytes这段剩余空间可能不是 DMA 对齐长度,建议在剩余空间不足一帧(比如 1024 字节)时就停止,避免读到未对齐地址。

5.4 返回结果解析与错误码判断

识别成功后,百度返回的 JSON 结构大致如下:

{ "err_no": 0, "err_msg": "success", "corpus_no": "xxx", "sn": "xxx", "result": ["打开客厅灯"] }

result数组里第一项就是识别文本。如果err_no不是 0,你要学会看错误码。我整理几个实战中常见的:

错误码含义解决办法
3300输入参数不正确检查请求头 Content-Type、dev_pid、access_token
3301音频质量过差检查麦克风接线、供电、噪声,换安静环境
3302音频过长单次请求限制 60 秒,把录音缩短
3303音频过大原始 PCM 超过 4MB,控制时长
3304采样率不符合要求代码里 sample_rate 必须 16000
3305音频格式不符合要求确认发送的是裸 PCM,不是 WAV 或 base64
3307服务繁忙稍后重试,或者检查免费额度是否耗尽
4001token 无效或过期重新获取 access_token

排查顺序我一般是:先看错误码,再查 token,再看 Content-Type,最后怀疑麦克风硬件。如果你同时改了录音代码和请求代码,报 3300 就先别碰硬件,多半是请求格式有误。

6. 常见问题与实战排坑记录

6.1 认证失败 401 与 token 过期

有段时间我的设备跑一两天就会报 401,后来定位是 token 过期。我是怎么发现的?打印返回的 JSON 看到error字段,再去控制台对比expires_in,确认 token 有效期。解决办法就是前面说的缓存 token 到 NVS,在调用前判断“获取时间 + expires_in”是否早于当前时间,过期就重新获取。

如果你用的是WiFiClientSecure,还要注意系统时钟。TLS 握手会校验证书有效期,设备时间不对可能导致证书校验失败。ESP32 默认没有 RTC 电池,每次开机时间从 1970 年开始,需要联网校时。我用的方法是请求 token 时顺便通过 HTTP 响应头里的Date字段粗校时间,或者直接调用configTime走 NTP。开发阶段setInsecure()可以绕过这个坑,但产品化一定得校时。

另外一个很容易忽略的点:百度的 token 接口是aip.baidubce.com,语音识别接口是vop.baidu.com,两者域名不同,证书主体也不同。如果你把 token 接口的证书固定到语音识别请求里,会出现服务端证书不匹配的问题。

6.2 音频格式报错 3300 系列

这个系列是重灾区。我接到过不少私信,说代码没问题但一直报 3300。每次我都先问一句:你发送的是原始 PCM 还是 WAV?是不是把audio/pcm写成了audio/wav?是不是采样率填了 44100?

3300 通常不是硬件问题,而是请求格式问题。我总结几个最隐蔽的坑:

一是Content-Type少了rate=16000参数,服务端不知道采样率,直接拦截。二是dev_pid写错,比如写成 1937(极速版普通话)却用标准版前置逻辑,返回的参数校验逻辑也不同。三是 URL 里的access_token带上了引号或换行符,导致鉴权失败。

如果你用的是电脑端 Python 脚本先调试,再移植到 ESP32,我建议调试时用 curl 发一次裸 PCM 请求,确认接口没问题,再回嵌入式环境找差异。curl 命令大致是:

curl -X POST -H "Content-Type: audio/pcm; rate=16000" --data-binary @test.pcm "https://vop.baidu.com/server_api?dev_pid=1537&access_token=你的token"

这样能快速区分是接口配置问题还是 ESP32 代码问题。我在好几个项目里都是靠这招定位错误的。

6.3 识别结果不准或为空

识别返回成功但是文字乱码,或者干脆是空字符串,这时候问题基本在音频质量。先做一件事:把录音数据导出来在电脑上听一下。怎么导?ESP32 通过串口把 buffer 数据发到电脑,存成.pcm文件,再用 Audacity 导入,设置采样率 16000、单声道、16bit,播放听听。

我遇到过一种“说话声太小”的情况,原因是 INMP441 的灵敏度有限,人离麦克风超过 30 厘米识别率就直线下降。解决方法是把麦克风固定朝向说话人,保持 10 到 20 厘米距离。如果环境噪声大,可以考虑加一个简单的静音检测,只有声音超过阈值才录音,避免静音段浪费上下文。

还有一个细节:录音开头和结尾容易有咔哒声或爆音,可以在发送前裁掉前 100ms 和后 100ms 的数据。方法很简单,从audio_buffer的偏移量开始发送,长度减去 3200 字节(100ms × 3200 字节/100ms)。这个小技巧能提升一点点识别率,尤其适合指令式短句。

6.4 运行内存不够、卡死、丢数据

ESP32 的堆内存是宝贵资源,动态分配不当会让人焦头烂额。ArduinoJson 的DynamicJsonDocument在解析响应时会占用栈和堆,如果你同时开了一个 160KB 的录音 buffer,再解析 2KB 的 JSON,内存就有点紧张。解决思路是把 buffer 定义成全局静态数组,不要用malloc动态分配;解析完后立刻让doc变量离开作用域释放内存。

另外一个卡死的典型原因是i2s_read的阻塞超时参数设置不当。portMAX_DELAY会让线程无限期等待数据,如果 I2S 驱动异常,程序会卡死。我建议超时设置成1000 / portTICK_PERIOD_MS,并且在主循环里加一个看门狗喂狗逻辑,万一哪里卡住至少能自动重启。

数据丢失的问题多半出在 DMA 缓冲区大小不够或者读取不及时。dma_buf_countdma_buf_len我习惯用 8 和 1024,也就是 8 个 1024 字节的 DMA 缓冲。如果你发现录音数据有周期性断点,可以增大dma_buf_count到 16,代价是多占 16KB 内存,两者权衡。

6.5 结合热搜话题补充的几类扩展方向

不少读者在社区里问过 ESP32 相关的延展问题,我自己也做过一些尝试,这里统一说说。

关于“ESP32 接入米家 mesh”或“ESP32 IDF 接入语音识别”,这两个方向都是本项目的自然延伸。米家 mesh 主要走 BLE Mesh,ESP32 的蓝牙协议栈支持 BLE,可以承担网关或子设备角色,但和本项目用的是同一套硬件,不同的只是通信协议。ESP-IDF 版语音识别就是把 Arduino 代码翻译成 IDF 组件,核心还是 I2S 采集和 HTTPS 请求,逻辑完全一致,只是 API 风格更底层。

关于“esp32 烧录器”和“esp32 烧录方式”,我建议新手上手直接用 USB 串口烧录,开发板自带 USB 转串口。如果需要批量烧录,可以买一个 ESP32 烧录器(其实就是 USB 转 TTL 模块),接 GND、TX、RX、3V3、EN、GPIO0 六根线,配合 esptool 命令行操作。注意 GPIO0 在下载时要拉低,ESP32 才会进入下载模式。

“ESP32 对比 STM32”这句话也是搜索高频词。我的结论是:如果项目有复杂联网需求,ESP32 优先;如果项目强依赖实时控制、需要车规级稳定性,STM32 优先。两者也可以组合使用,STM32 负责控制,ESP32 负责联网和语音识别,通过串口通信。这个组合实测下来很稳,我后面会单独写一篇。

个人实操心得

最后说点掏心窝的话。这个项目我前后调了两周才跑通,最耽误时间的不是代码本身,而是对音频的“敬畏感不足”。一开始我也觉得,录音、上传、拿结果不就三步吗?结果被 I2S 的声道配置、PCM 的格式、HTTPS 的证书这三座大山反复教育。后来我把每一步都拆开验证:先用电脑生成一段 PCM 文件直接调接口,确认云端能识别;再让 ESP32 录音后通过串口发到电脑检查音频质量;最后才把采集和上传串成闭环。这条“先验证云端,再验证采集,最后端到端联调”的路径,我建议所有做同类项目的朋友都采用,能省下一大半的调试时间。ESP32 加百度智能云这套组合,做语音控制、语音打卡、语音助手都足够顺手,希望这篇指南能帮你少踩几个我踩过的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 10:25:50

2026年可删的5个npm包:原生Node.js替代方案全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:25:40

Homebrew 可视化工具 BrewUI:从 CLI 到 TUI 的开发实践

"你还在一个个执行brew outdated && brew upgrade&#xff1f;"同事那天看着我终端里滚动的日志&#xff0c;随口问了一句。我当时正盯着十几条更新记录&#xff0c;单线程地敲键盘&#xff0c;说实话也有点烦了。命令行当然强大&#xff0c;但包管理这件事本…

作者头像 李华
网站建设 2026/9/19 10:21:43

纵横交叉算法优化BP神经网络的电力负荷预测与Matlab实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 10:20:33

Node.js安装配置全攻略:从版本选择到环境变量与npm镜像源

1. 先搞明白&#xff1a;Node.js 是个运行时&#xff0c;不是一门语言很多人第一次接触 Node.js 时&#xff0c;会把它当成一门编程语言&#xff0c;其实不是。Node.js 本质上是一个基于 Chrome V8 引擎的 JavaScript 运行时环境&#xff0c;它的作用就是让 JavaScript 代码能在…

作者头像 李华
网站建设 2026/9/19 10:18:08

ESP32+MAX30102心率血氧监测实战:从硬件连接到信号处理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华