Skip to content

Commit a521e77

Browse files
committed
Add one post on esp-sr
1 parent 98bf853 commit a521e77

File tree

4 files changed

+459
-0
lines changed

4 files changed

+459
-0
lines changed

_org/2025-04-09-ai-box-esp-sr.org

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#+OPTIONS: ^:nil
2+
#+BEGIN_EXPORT html
3+
---
4+
layout: default
5+
title: AI聊天盒子 - 使用esp-sr和Rust语言编写语音识别程序
6+
tags: [ESP32, esp-sr, esp-skainet, AI, LLM]
7+
nav_order: {{ page.date }}
8+
sync_wexin: 1
9+
---
10+
#+END_EXPORT
11+
12+
* AI聊天盒子 - 使用esp-sr和Rust语言编写语音识别程序
13+
14+
** 前言
15+
16+
我在查找资料时,看到一个叫[[https://github.com/78/xiaozhi-esp32][xiaozhi-esp32]]的项目。这个项目实现了一个AI盒子,允许用户自定义一个可以交互的虚拟好友。这个项目非常受欢迎,但这个AI盒子需要一个后台服务xiaozhi.me来支撑,AI盒子作为语音处理的前端设备。看来已经有人想到AI盒子的想法了,我打算实现一个不需要额外增加后台服务的AI盒子,可以直接向LLM提问。
17+
18+
这一篇文章是我尝试使用esp-sr实现语音识别的一个总结。本文设计的代码可以在代码仓[[https://github.com/paul356/ai-chatbox][ai-chatbox]](链接在附录中),使用的硬件是XIAO ESP32S3 Sense。
19+
20+
本系列其他文章
21+
1. [[https://paul356.github.io/2025/03/15/wake-word-detect.html][AI聊天盒子 - 概念设计]]
22+
23+
** 编写语音识别程序
24+
25+
*** 创建Rust on ESP项目并引入esp-sr
26+
27+
需要解决的第一个问题是如何将esp-sr添加到Rust on ESP项目中。首先创建一个Rust on ESP项目,进入Rust on ESP开发环境(如何创建Rust on ESP开发环境请参考[[https://paul356.github.io/2024/11/11/rust-on-esp-series_1.html][“准备Rust on ESP开发环境”]]),执行命令 ~cargo generate esp-rs/esp-idf-template cargo~ , 根据提示创建一个空的Rust on ESP项目。由于esp-sr不是esp-idf中默认包含的代码库,需要我们显式地引入这个代码库。我们知道在C/C++项目中可以通过 ~idf.py add-dependency espressif/esp-sr~ 来添加扩展库,但是在Rust on ESP项目中如何做呢?通过查看[[https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md][esp-idf-sys编译选项]],可以在 ~package.metadata.esp-idf-sys~ 配置段中添加 ~extra_components~ 配置项来引入扩展库。在Cargo.toml中添加如下配置。
28+
29+
#+begin_src toml
30+
[package.metadata.esp-idf-sys]
31+
esp_idf_tools_install_dir = "global"
32+
esp_idf_sdkconfig = "sdkconfig"
33+
esp_idf_sdkconfig_defaults = ["sdkconfig.defaults", "sdkconfig.defaults.ble"]
34+
extra_components = [
35+
{ remote_component = { name = "espressif/esp-sr", version = "^2.0.0" }, bindings_header = "esp_sr_bind.h", bindings_module = "esp_sr" }
36+
]
37+
#+end_src
38+
39+
因为Rust需要为用到的esp-sr接口生成Rust绑定,我们还需要添加一个头文件 ~esp_sr_bind.h~ ,其中包含了可能用到的esp-sr接口头文件。Rust使用的绑定工具bindgen通过这个头文件来查找哪些C接口需要生成Rust绑定。生成的Rust接口可以在文件 ~target/xtensa-esp32s3-espidf/debug/build/esp-idf-sys-*/out/bindings.rs~ 中看到。
40+
41+
另外我们还需要添加partition.csv,内容如下。我们需要增加一个名为model的flash分区,为esp-sr生成的AI模型提供存储空间。
42+
43+
#+begin_src csv
44+
# Espressif ESP32 Partition Table
45+
# Name, Type, SubType, Offset, Size
46+
factory, app, factory, 0x010000, 2048K
47+
model, data, spiffs, , 5168K
48+
#+end_src
49+
50+
51+
*** 使用esp-sr接口
52+
53+
esp-sr库主要包含语音前端框架、唤醒词检测、语音活动检测、命令词识别、文字转语音功能五个模块。这里语音前端框架是整个语音处理流程的框架模块,支持噪声抑制、回声抑制、语音活动检测、唤醒词检测等算法。命令词识别模块的输入数据也需要经过语音前端框架的处理。唤醒词检测支持用较低的资源开销检测特定的唤醒词,唤醒词是esp-sr预包含的,如许定制唤醒词需要向乐鑫公司提需求。语音活动检测是检测用户有无语音输入。命令词识别比唤醒词检测更加灵活,支持用户自定义命令词,可以识别多达200个命令,但消耗的资源要多很多,这两种功能一般会配合使用。文字转语音就是将文字转成语音的功能。目前ai-chatbox只用到了前四个模块,文字转语音功能以后再尝试。
54+
55+
与语音识别相关的代码主要包含在 ~main.rs~ 中。这个文件会初始化硬件和创建AFE等资源对象,然后创建两个任务。第一个Feed任务持续地从麦克风读取数据,并输入到AFE流水线中;另一个Fetch任务从AFE流水线获取经过算法处理的数据和检测结果,进行下一步的处理。
56+
57+
[[/images/ai-chatbox-main-tasks.png]]
58+
59+
Fetch任务还维护了一个状态机,不同的事件会触发Fetch任务在几个状态之间切换。
60+
61+
[[/images/ai-chatbox-fetch-states.png]]
62+
63+
下面我们看代码中如何使用esp-sr接口。首先我们需要创建AFE相关的对象,代码在 ~main~ 函数中。这段代码先设置AFE的配置参数,指定模型所在的分区、模型类型和运行模式等,然后通过调用 ~esp_afe_handle_from_config~ 和 ~create_from_config~ 创建了AFE方法对象和AFE数据对象。至于为什么esp-sr把AFE拆成了两个对象,我猜是因为方便把接口和实现隔离开来,隐藏实现细节。这里的 ~call_c_method~ 是个Rust宏,用于方便地在Rust中使用C函数指针,等于C++中的 ~afe_handle->create_from_config(afe_config)~ 。
64+
65+
#+begin_src Rust
66+
let part_name = CString::new("model").unwrap();
67+
let models = unsafe { esp_srmodel_init(part_name.as_ptr()) };
68+
69+
let input_format = CString::new("M").unwrap();
70+
let afe_config = unsafe {
71+
afe_config_init(
72+
input_format.as_ptr(),
73+
models,
74+
esp_sr::afe_type_t_AFE_TYPE_SR,
75+
esp_sr::afe_mode_t_AFE_MODE_LOW_COST,
76+
)
77+
};
78+
79+
// Print the AFE configuration
80+
print_afe_config(afe_config);
81+
82+
let afe_handle = unsafe { esp_afe_handle_from_config(afe_config) };
83+
let afe_data = call_c_method!(afe_handle, create_from_config, afe_config)?;
84+
unsafe { afe_config_free(afe_config) };
85+
#+end_src
86+
87+
下一步创建命令词识别相关的对象。和AFE类似命令词识别也需要两个对象,一个接口对象和一个数据对象。创建好命令词识别相关的对象后我使用了 ~esp_nm_commands_clear/add/update~ 接口动态注册了一个命令词“wo you wen ti”。
88+
89+
#+begin_src Rust
90+
let prefix_str = Vec::from(esp_sr::ESP_MN_PREFIX);
91+
let chinese_str = Vec::from(esp_sr::ESP_MN_CHINESE);
92+
let mn_name = unsafe {
93+
esp_srmodel_filter(
94+
models,
95+
prefix_str.as_ptr() as *const i8,
96+
chinese_str.as_ptr() as *const i8,
97+
)
98+
};
99+
100+
let multinet = unsafe { esp_mn_handle_from_name(mn_name) };
101+
let model_data = call_c_method!(multinet, create, mn_name, 6000)?;
102+
103+
unsafe {
104+
esp_mn_commands_clear();
105+
esp_mn_commands_add(1, Vec::from(b"wo you wen ti\0").as_ptr() as *const i8);
106+
esp_mn_commands_update();
107+
}
108+
#+end_src
109+
110+
创建好了AFE和命令词识别相关的对象,下面我们创建两个FreeRTOS任务分别从麦克风获取数据输入到AFE流水线中和从AFE流水线抓取处理过的数据和结果。我们分别看一下这两个任务的代码。先看Feed任务的代码。代码在函数 ~inner_feed_proc~ 中。我先初始化了麦克风硬件,并分配语音数据缓存,然后在loop中不断从麦克风读取数据,然后调用AFE函数对象的 ~feed~ 方法,将数据输入AFE流水线中。
111+
112+
#+begin_src Rust
113+
fn inner_feed_proc(feed_arg: &Box<FeedTaskArg>) -> anyhow::Result<()> {
114+
let peripherals = Peripherals::take()?;
115+
let mut mic = init_mic(
116+
peripherals.i2s0,
117+
peripherals.pins.gpio42,
118+
peripherals.pins.gpio41,
119+
)?;
120+
121+
let chunk_size = call_c_method!(feed_arg.afe_handle, get_feed_chunksize, feed_arg.afe_data)?;
122+
123+
let channel_num = call_c_method!(feed_arg.afe_handle, get_feed_channel_num, feed_arg.afe_data)?;
124+
125+
log::info!("[INFO] chunk_size {}, channel_num {}", chunk_size, channel_num);
126+
127+
let mut chunk = vec![0u8; 2 * chunk_size as usize * channel_num as usize];
128+
129+
loop {
130+
mic.read(chunk.as_mut_slice(), 100)?;
131+
let _ = call_c_method!(feed_arg.afe_handle, feed, feed_arg.afe_data, chunk.as_ptr() as *const i16)?;
132+
}
133+
134+
Ok(())
135+
}
136+
#+end_src
137+
138+
接下来再看Fetch任务中调用esp-sr接口的地方。代码主要位于 ~inner_fetch_proc~ 函数中。为了突出调用esp-sr的地方,我删除了其中记录Wav文件相关的代码。主要部分也是一个loop循环过程,每次循环都会调用AFE流水线的 ~fetch~ 方法,这个方法会返回流水线处理过的数据和结果,然后执行一个匹配当前状态的match语句。
139+
140+
#+begin_src Rust
141+
fn inner_fetch_proc(arg: &Box<FetchTaskArg>) -> anyhow::Result<()> {
142+
...
143+
loop {
144+
// Always fetch data from AFE
145+
let res = call_c_method!(afe_handle, fetch, afe_data)?;
146+
if res.is_null() || unsafe { (*res).ret_value } == esp_sr::ESP_FAIL {
147+
log::error!("Fetch error!");
148+
break;
149+
}
150+
151+
// Handle the data based on current state
152+
match state {
153+
State::WAKE_WORD_DETECTING => {
154+
if unsafe { (*res).wakeup_state } == esp_sr::wakenet_state_t_WAKENET_DETECTED {
155+
let next_state = State::CMD_DETECTING;
156+
log::info!("Wake word detected. State transition: {:?} -> {:?}", state, next_state);
157+
158+
call_c_method!(afe_handle, disable_wakenet, afe_data)?;
159+
call_c_method!(multinet, clean, model_data)?;
160+
state = next_state;
161+
}
162+
},
163+
164+
State::CMD_DETECTING => {
165+
let mn_state = call_c_method!(multinet, detect, model_data, (*res).data)?;
166+
if mn_state == esp_sr::esp_mn_state_t_ESP_MN_STATE_DETECTED {
167+
...
168+
let next_state = State::RECORDING;
169+
log::info!("Command detected (ID: {}). State transition: {:?} -> {:?}",
170+
command_id_str, state, next_state);
171+
172+
...
173+
state = next_state;
174+
175+
} else if mn_state == esp_sr::esp_mn_state_t_ESP_MN_STATE_TIMEOUT {
176+
let next_state = State::WAKE_WORD_DETECTING;
177+
log::info!("Command detection timeout. State transition: {:?} -> {:?}", state, next_state);
178+
179+
call_c_method!(afe_handle, enable_wakenet, afe_data)?;
180+
state = next_state;
181+
}
182+
},
183+
184+
State::RECORDING => {
185+
...
186+
if vad_state == sys::esp_sr::vad_state_t_VAD_SILENCE {
187+
silence_frames += 1;
188+
if silence_frames >= frames_per_second { // More than 1 second of silence
189+
let next_state = State::WAKE_WORD_DETECTING;
190+
log::info!("Detected {} frames of silence. State transition: {:?} -> {:?}",
191+
silence_frames, state, next_state);
192+
...
193+
194+
// Return to wake word detection
195+
call_c_method!(afe_handle, enable_wakenet, afe_data)?;
196+
state = next_state;
197+
}
198+
} else {
199+
// Reset silence counter when we detect speech
200+
if silence_frames > 0 {
201+
log::debug!("Speech detected after {} silent frames, resetting silence counter", silence_frames);
202+
}
203+
silence_frames = 0;
204+
}
205+
}
206+
}
207+
}
208+
209+
Ok(())
210+
}
211+
#+end_src
212+
213+
先看 ~WAKE_WORD_DETECTING~ 状态的处理,唤醒词检测的结果保存在 ~wakeup_state~ 字段中。如果字段等于 ~WAKENET_DETECTED~ ,就说明用户说出了唤醒词“Hi,lexin”。如果状态机是 ~CMD_DETECTING~ 状态,代码会调用命令词识别接口对象的 ~detect~ 方法。命令词识别算法并不在AFE流水线中,但是需要输入AFE流水线处理过的数据。 ~detect~ 方法会返回识别的结果,通过检查返回值判断有无识别到命令词。再如果状态机是 ~RECORDING~ 状态,代码会用到声音活动检测算法。这个算法包含在AFE流水线中,所以只需要检查 ~fetch~ 方法返回结果的 ~(*res).vad_state~ 字段就可以了。如果字段等于 ~VAD_SILENCE~ ,就表明用户没有说话。当前静音达到一定的时间,状态机会返回 ~WAKE_WORD_DETECTING~ 状态。
214+
215+
*** 编译和上传固件
216+
217+
项目使用esp-sr时比普通的Rust on ESP项目多了一个刷写模型的步骤。首先接上ESP32S3进入 *Rust on ESP环境* 。在运行完 ~cargo espflash flash -p /dev/ttyACM0 --flash-size 8mb~ 命令后我们还要刷写esp-sr生成的模型。在 *esp-idf环境下* 运行命令 ~esptool.py --chip esp32s3 -p /dev/ttyACM0 -b 460800 --before=default_reset --after=hard_reset write_flash --flash_mode dio --flash_freq 80m --flash_size 8MB 0x210000 ./target/xtensa-esp32s3-espidf/debug/build/esp-idf-sys-ac*/out/build/srmodels/srmodels.bin~ 就可以将模型写入"model"分区中。
218+
219+
** 总结
220+
221+
本文介绍了如何使用esp-sr来完成唤醒词检测、命令识别、语音活动检测等任务,以及如何在Rust on ESP项目中使用esp-sr。在完成这个项目过程中[[https://github.com/espressif/esp-skainet][esp-skainet]]给了我很多启发,关于如何使用麦克风我还参考了esp-idf中的[[https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_recorder][i2s_recorder]]例子。本系列的后续文章中我会继续探索如何在Rust on ESP项目中使用LLM API,欢迎大家关注和转发,也欢迎大家和我交流。
222+
223+
** 链接
224+
225+
1. ai-chatbox - https://github.com/paul356/ai-chatbox
226+
2. esp-skainet - https://github.com/espressif/esp-skainet
227+
3. i2s_recorder - https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_recorder

0 commit comments

Comments
 (0)