ESP32 + Rust 开发的简易语音助手

最近花费37大洋,买了个ESP32的开发板,简单体验了一下用Rust做嵌入式开发,但因为对这部分确实不熟,所以在尝试的过程中遇到不少问题,在此分享一下。不过,虽说是"嵌入式开发",但实际上基本上没有涉及到十分底层的地方,如果有想从纯软件开发转到嵌入式的同学,可以来参考一下这篇文章。

项目地址:https://github.com/xgpxg/esp32-voice-assistant-demo

硬件准备

因为要做一个可以对话的语音助手,那肯定需要连接网络的,因为目前来说,在几十块的板子上跑LLM还是不现实的。所以ESP32是比较合适的,它自带WiFi和蓝牙,并支持I2S,可以方便的处理音频输入和输出。

ESP32有不同的型号,主要区别如下(截取部分):

本次选择其中"最贵的"ESP32-S3

然后语音输入需要一个麦克风和喇叭,看下来INM441MAX98357A是大家用的比较多的,价格也便宜,就几块钱。

硬件只需要这3个就可以了。

这是接好线后的,有点乱。

开发环境准备

ESP32官方提供了Rust的SDK,叫做esp-svc-idf,也有比较详细的环境配置文档,可参考:https://github.com/esp-rs/esp-idf-template

我的环境为:Windows + WSL2

需要安装以下必要组件:

shellp 复制代码
# 依赖包
sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0

# 工具包
cargo install cargo-generate
cargo install ldproxy
cargo install espup
cargo install espflash

# 安装esp
espup install

# 安装usbipd(用于将Windows的USB设备提供给WSL)
winget install usbipd

烧录程序:

直接 cargo run -r 即可,不用debug模式是因为debug打包出来的体积较大,传输也慢,不太合适。

在安装以及构建过程中,需要从github下载文件,会比较慢,或者超时,建议开启代理。

连接麦克风

麦克风用的是INM441,它长这样:

其中各个引脚的功能为:

  • VDD:电源输入,3.3V。
  • GND:接地。
  • SD:数据输入。
  • L/R:左声道/右声道。
  • WS:字选择时钟,标识传输的数据是左声道还是右声道。
  • SCK:也就是BCLK,用于数据位同步。

然后我们需要将它连接到ESP32-S3上。

其中L/R为低电平时为左声道,高电平时为右声道。默认接地为左声道即可。

SDWSSCK这三个,可自己选择IO口接,和代码中分配的保持一致即可。

连接喇叭

喇叭其实包含两部分:

  • 解码器 MAX98357A ,将数字信号转为模拟信号
  • 一个小喇叭,用来振动发声。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其中各个引脚的功能为:

  • LRC:即左声道/右声道时钟,和麦克风的 SD 类似。
  • BCLK:用于数据位同步,和麦克风的SCK类似。
  • DIN:数据输入。
  • GAIN:暂未用到。
  • SD:暂未用到。
  • VDD:电源输入,2.5V - 5.5V。
  • GND:接地。

编写代码

创建项目

esp-svc-idf 提供了一个基于cargo-generate的快速生成模板项目的方式,可以快速创建一个项目:

shell 复制代码
cargo generate esp-rs/esp-idf-template cargo

创建完后,执行 cargo run -r 尝试将程序烧录到板子上,如果之前的环境配置没问题,就可以在控制台看到相应的日志输出。

在烧录前需要将板子的COM口连接到电脑,在WSL下,需要在Windows上执行以下操作:

shell 复制代码
# 查看有哪些设备
usbipd list

# 输出
Connected:
BUSID  VID:PID    DEVICE                                                        STATE
1-3    05ac:12a8  Apple Mobile Device USB Composite Device                      Not shared
1-4    1a86:55d3  USB-Enhanced-SERIAL CH343 (COM3)                              Shared
1-6    1532:0084  Razer DeathAdder V2, USB 输入设备                             Not shared
1-9    13d3:56a2  USB2.0 HD UVC WebCam                                          Not shared
1-10   0b05:1866  USB 输入设备                                                  Not shared
1-14   8087:0aaa  英特尔(R) 无线 Bluetooth(R)                                   Not shared

# 找到板子对应的COM口,然后将其转移到WSL
usbipd bind --busid=1-4
usbipd attach --wsl --busid=1-4

# 在WSL查看连接的设备
ls /dev/ttyACM*

# 输出:/dev/ttyACM0

这样就能在WSL上向板子烧录程序了。

连接WiFi

ESP32带有WiFi模块,可以连接WiFi(STA模式),也可以作为热点提供一个WiFi出来(AP模式)。

为了能让板子连接到网络,需要提供一个Http Server以及配置页面来配置需要连接的WiFi账号和密码。

所以,整体思路如下:

  • 启动一个HTTP服务,用于配置的读写。
  • WiFi同时启用STA和AP,STA用于连接到已知网络,AP用于访问配置页面。
  • 当网络信息已经配置时,后续启动自动连接。

esp-idf-svc 也提供了HTTP的服务端模块:EspHttpServer

rust 复制代码
// 创建HTTP服务
let mut server = EspHttpServer::new(&Configuration::default()).unwrap();
// 注册静态文件
server::register_static_files(&mut server).unwrap();
// 网络相关接口
server::network::register(&mut server, wifi.clone(), nvs.clone()).unwrap();

详细代码可参考仓库的 main.rsserver 模块。

需要注意的时,EspHttpServer 无法实现动态路由,并且配置相关功能使用频率较低,所以前端尽量不要使用太重的框架,以此来减少可执行文件体积。如果是熟悉 Vue 的同学,可以使用这个轻量版的:https://github.com/vuejs/petite-vue ,适合在嵌入式中集成。

前端部分在 pages 目录下

在WiFi配置完成后,可以将其写入到磁盘,也就是 NVS 中,在下次启动时,从 NVS 中读取SSID和密码后自动连接。

rust 复制代码
fn init_wifi(wifi: &mut EspWifi<'static>, nvs: &EspDefaultNvs) -> anyhow::Result<()> {
    let ssid = Config::get_wifi_ssid(nvs);
    let password = Config::get_wifi_password(nvs);

    // 混合模式,同时支持AP和STA
    let cfg = wifi::Configuration::Mixed(
        ClientConfiguration {
            ssid: ssid.as_str().try_into().unwrap(),
            password: password.as_str().try_into().unwrap(),
            ..Default::default()
        },
        AccessPointConfiguration {
            ssid: SSID.try_into().unwrap(),
            ssid_hidden: false,
            auth_method: AuthMethod::WPA2Personal,
            password: PASSWORD.try_into().unwrap(),
            channel: CHANNEL,
            ..Default::default()
        },
    );

    wifi.set_configuration(&cfg)?;

    wifi.start()?;

    // 尝试连接
    if !ssid.is_empty() {
        log::info!("Wifi连接中: {}", ssid);
        wifi.connect()?;
    }

    Ok(())
}

麦克风输入以及扬声器输出

这两个都比较简单,都是标准的I2S协议,芯片已经封装好了,初始化后,调用 readwrite 即可,例如:

rust 复制代码
let std_config = StdConfig::new(
    Config::default(),
    StdClkConfig::from_sample_rate_hz(Self::SAMPLE_RATE),
    StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, SlotMode::Mono),
    StdGpioConfig::new(false, false, false),
);
let mclk = AnyIOPin::none();
let mut i2s = I2sDriver::<I2sRx>::new_std_rx(i2s1, &std_config, bclk, sd, mclk, ws)?;

需要注意的是不要使用 StdConfig::philips() 的默认配置,它的默认配置的 SlotModeStereo,即双声道的,而模型侧要求的输入音频是单声道,所以如果不匹配会导致无法正确识别音频。

同样的,在喇叭输出时,采样率和声道也要和模型返回的匹配。

调用模型

完成一次语音对话,在非多模态的情况下,需要3个模型:

  • 语音识别(STT)
  • 语言模型(LLM)
  • 文本转语言(TTS)

其中STT和TTS均使用Websocket,LLM使用HTTP。模型调用这一部分按照模型提供商给的API文档完成逻辑即可。

整体流程如下:

  • SST 将麦克风的音频转为文字。
  • 将文字发送给 LLM。
  • TTS 将LLM 返回的文字转为音频。
  • 输出到喇叭播放。

SDK默认未启用Websocket,需要在 sdkconfig.defaults 中开启:

text 复制代码
CONFIG_HTTPD_WS_SUPPORT=y

还有需要注意的有两个地方:一是 TLS 配置问题,二是 Channel 阻塞问题。

HTTPS 和 WSS 的 TLS 配置

对于这两个协议,需要启用 TLS,如果未启用,则会收到以下错误:

text 复制代码
E (9892) esp-tls-mbedtls: No server verification option set in esp_tls_cfg_t structure. Check esp_tls API reference
E (9892) esp-tls-mbedtls: Failed to set client configurations, returned [0x8017] (ESP_ERR_MBEDTLS_SSL_SETUP_FAILED)
E (9902) esp-tls: create_ssl_handle failed
E (9912) esp-tls: Failed to open new connection

esp-idf-svc 给的样例中,给出了样例配置,需要先获取服务器的证书链,然后配置为:

rust 复制代码
const SERVER_ROOT_CERT: &[u8] = b"";
let config = EspWebSocketClientConfig {
  server_cert: Some(X509::pem_until_nul(SERVER_ROOT_CERT)),
   ..Default::default()
};

这样需要手动导出服务器的证书,并固化在代码中,是不太好的。

另一种方式是使用如下配置:

rust 复制代码
let mut config = EspWebSocketClientConfig::default();
config.crt_bundle_attach = Some(esp_idf_svc::sys::esp_crt_bundle_attach);
config.use_global_ca_store = true;

注意需要增加依赖:

toml 复制代码
critical-section = { version = "1.1", features = ["std"], default-features = false }
标准库中mpsc::channel导致的线程阻塞问题

在处理Websoket事件时,如果有多个地方调用了rx.recv() 可能会导致线程阻塞,可以更换为embassy-sync下的Channel(类型Tokio的),或者使用 循环 + try_recv +休眠 的方式。例如:

rust 复制代码
loop {
    if let Ok(event) = rx.try_recv() {
        match event {
            WsEvent::ResultGenerated(text) => result.push_str(&text),
            _ => {
                break;
            }
        }
        break;
    }
    Timer::after_millis(100).await;
}

虽然embassy-syncChannel挺好用,但是,esp-idf-svc 提供的Websocket客户端的回调函数是同步的,无法利用embassy-sync的异步性。

启用片外RAM

由于ESP32-S3的内存只有 512KB,对于习惯了软件开发的同学来说,因为通常情况下可能不会注意这几KB的内存,所以可能导致内存不够用,不过,我们可以通过启用片外内存(PSRAM)来扩展。
ESP32-S3 有 8M 的 PSRAM 可用,对于语音对话来说是完全够用的。

PSRAM 默认是不开启的,需要在 sdkconfig.defaults 中启用:

text 复制代码
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y

需要注意的是,当启用了PSRAM后 33-37 这几个口就不能用了,否则会收到如下信息导致一直重启:

text 复制代码
W (51) boot.esp32s3: PRO CPU has been reset by WDT.
W (56) boot.esp32s3: APP CPU has been reset by WDT.

具体原因为:

相关推荐
嵌入小生0072 小时前
线程 --- 嵌入式(Linux)
linux·vscode·vim·嵌入式·线程·进程
lzxdyzx2 小时前
数据库操作与数据管理——Rust 与 SQLite 的集成
数据库·rust·sqlite
键盘鼓手苏苏3 小时前
Flutter for OpenHarmony:random_string 简单灵活的随机字符串生成器(验证码、密钥、UUID) 深度解析与鸿蒙适配指南
开发语言·flutter·华为·rust·harmonyos
班公湖里洗过脚3 小时前
《通过例子学 Rust》第16章 特质trait
rust
班公湖里洗过脚3 小时前
《通过例子学Rust》第17章 使用 macro_rules! 来创建宏
rust
charlie11451419113 小时前
RK3568跑Arch Linux全路程指南(以正点原子的RK3568开发板为例子)
linux·嵌入式·rootfs·教程·环境配置·嵌入式linux·工程实践
非鱼䲆鱻䲜14 小时前
淘晶驰串口屏使用波形图控件,接收单片机或者串口助手数据生成图像的区别
单片机·嵌入式·串口屏
ChenYY~16 小时前
入门分享篇:一、工欲善其事,必先利其器
计算机·程序员·机器人·嵌入式·typora·工具·软件开发·obsidian
Ivanqhz17 小时前
半格与数据流分析的五个要素(D、V、F、I、Λ)
开发语言·c++·后端·算法·rust