硬件介绍:
这是我参加2026年寒假一起练活动,使用的人工智能硬件实验套件平台。核心主控是一个Seeed XIAO ESP32-S3 Sensen。这个模块是一个集成了摄像头传感器、数字麦克风和支持 SD 卡的功能。处理器 ESP32-S3R8 SoC,支持 2.4GHz WiFi 和低功耗蓝牙® BLE 5.0 双模,适用于多种无线应用。电路板还配有数字麦克风,用于语音感应和音频识别。
任务选择:
我选择的是任务二:智能语音氛围灯带。使用语音、手势、按键等方式控制LED灯带显示模式。
任务分解:

将任务分解为三大块。第一块是语音识别,负责监听环境中的语音命令字。第二块是手势识别,通过摄像头发现手势信息。最后一块是LED驱动,通过RMT方式驱动LED灯条,预先设定了几种灯光效果,通过消息队列或按键切换效果、亮度、颜色、速度等信息。任务使用Vscode+ESP-IDF5.5实现。
任务实现:
1、LED驱动。RGB灯带,是由WS2812构成的,一共有10颗,只需要一个管脚(GPIO8)即可驱动。这里使用RMT方式驱动。设定了5种灯光效果进行切换。预设了几个颜色,用来展示。
#define LED_STRIP_GPIO_PIN 8 // 默认 GPIO 引脚
#define LED_STRIP_LED_COUNT 10 // 默认 LED 数量
// 灯光效果枚举
typedef enum {
EFFECT_STATIC, // 静态颜色
EFFECT_RAINBOW, // 彩虹效果
EFFECT_BREATHING, // 呼吸灯效果
EFFECT_BLINKING, // 闪烁效果
EFFECT_CHASING, // 追逐效果
} LightEffect;
// 常用颜色表
const Color ColorTable[] = {
{230, 0, 0}, // 中国红 (Red)
{0, 140, 140}, // 马尔斯绿 (Green)
{0, 47, 167}, // 克莱因蓝 (Blue)
{0, 49, 83}, // 普鲁士蓝
{129, 216, 208}, // 蒂芙尼蓝
{251, 210, 206}, // 申布伦黄
{10, 79, 87}, // 爱马仕橙
{64, 224, 208}, // 只此青绿
};
WS2812::WS2812(uint16_t ledCount, uint8_t pin)
: ledCount(ledCount), pin(pin), currentEffect(EFFECT_STATIC),
brightness(20), speed(50), r(0), g(0), b(0),
lastUpdate(0), currentColorIndex(0), ledStrip(nullptr) {}
void WS2812::begin()
{
// LED 灯带通用配置(不使用指定初始化语法)
led_strip_config_t strip_config = {};
strip_config.strip_gpio_num = pin;
strip_config.max_leds = ledCount;
strip_config.led_model = LED_MODEL_WS2812;
strip_config.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB;
strip_config.flags.invert_out = false;
// RMT 后端配置(同样避免指定初始化语法)
led_strip_rmt_config_t rmt_config = {};
rmt_config.clk_src = RMT_CLK_SRC_DEFAULT;
rmt_config.resolution_hz = LED_STRIP_RMT_RES_HZ;
rmt_config.mem_block_symbols = LED_STRIP_MEMORY_BLOCK_WORDS;
rmt_config.flags.with_dma = LED_STRIP_USE_DMA;
// 创建 LED 灯带对象
ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &ledStrip));
ESP_LOGI(TAGLED, "WS2812 initialized with %d LEDs on pin %d", ledCount, pin);
}
灯光效果可以变换的内容有:灯光效果、速度、亮度、颜色。所以设定了5个命令字,用来控制这些变量。1:切换灯光效果 2:修改亮度 3:修改速度 4:修改颜色 5:关闭灯光

2:按键控制。套件中提供的按键有四个按键,通过电阻分压的方式连接到GPIO1管脚上。但是按键模块是按照5V输入电压设计的,现在接的3.3V电压,导致无法使用全部的按键,这里参考别人的做法,按键模块的电源正负极对调,这样四个按键就都能使用了。这里还有个问题,控制LED灯,有5个命令字,这里只有4个按键,所以,按键只能发送4个命令字,无法关闭LED灯。
static const char *ButtonTAG = "Button";
// ADC校准参数
static esp_adc_cal_characteristics_t adc1_chars;
static const adc1_channel_t channel = ADC1_CHANNEL_0;
// 按键电压阈值定义 (根据实际电阻分压电路调整)
#define KEY_THRESHOLD_1 2400 // 第一个按键触发的ADC值
#define KEY_THRESHOLD_2 2000 // 第二个按键触发的ADC值
#define KEY_THRESHOLD_3 1500 // 第三个按键触发的ADC值
#define KEY_THRESHOLD_4 800 // 第四个按键触发的ADC值
#define KEY_DEBOUNCE_TIME 50 // 防抖时间(ms)
#define KEY_REPEAT_DELAY 400 // 按键重复触发间隔(ms)
// 按键状态枚举
typedef enum
{
KEY_NONE = 0,
KEY_1,
KEY_2,
KEY_3,
KEY_4
} key_state_t;
3:语音控制。这里语音控制使用了乐鑫提供的语音识别库来进行语音识别。将esp-sr组件添加到自己的项目中去。ESP-SR 是乐鑫科技推出的一个智能语音识别框架,专为 ESP32 和 ESP32-S3 芯片优化,支持高效的语音处理和命令识别功能。

参考着esp-sr中的例程修改代码。这里参考的是ESP32S3-EYE这块开发板的代码,需要参考着修改Seeed XIAO ESP32-S3 Sensen的I2S读取的部分硬件代码。重点修改这两个函数:
static esp_err_t bsp_i2s_init(i2s_port_t i2s_num, uint32_t sample_rate, int channel_format, int bits_per_chan)
{
esp_err_t ret_val = ESP_OK;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(i2s_num, I2S_ROLE_MASTER);
ret_val |= i2s_new_channel(&chan_cfg, NULL, &rx_handle);
i2s_pdm_rx_config_t pdm_rx_cfg = {
.clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(s_play_sample_rate),
/* The data bit-width of PDM mode is fixed to 16 */
.slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.clk = GPIO_NUM_42,
// Only ESP32-S3 can support 4-line PDM RX
.dins = {
GPIO_NUM_41,
},
.invert_flags = {
.clk_inv = false,
},
},
};
ret_val |= i2s_channel_init_pdm_rx_mode(rx_handle, &pdm_rx_cfg);
ret_val |= i2s_channel_enable(rx_handle);
#else
// i2s_config_t i2s_config = I2S_CONFIG_DEFAULT(16000, I2S_CHANNEL_FMT_ONLY_LEFT, 32);
i2s_config_t i2s_config = I2S_CONFIG_DEFAULT(sample_rate, I2S_CHANNEL_FMT_ONLY_LEFT, bits_per_chan);
i2s_pin_config_t pin_config = {
.bck_io_num = GPIO_I2S_SCLK,
.ws_io_num = GPIO_I2S_LRCK,
.data_out_num = GPIO_I2S_DOUT,
.data_in_num = GPIO_I2S_SDIN,
.mck_io_num = GPIO_I2S_MCLK,
};
ret_val |= i2s_driver_install(i2s_num, &i2s_config, 0, NULL);
ret_val |= i2s_set_pin(i2s_num, &pin_config);
#endif
return ret_val;
}
esp_err_t bsp_get_feed_data(bool is_get_raw_channel, int16_t *buffer, int buffer_len)
{
// afe入口是双声道,但是这里获取的声音是单声道的。
esp_err_t ret = ESP_OK;
size_t bytes_read;
int audio_chunksize = buffer_len / (sizeof(int32_t));
// ret = i2s_channel_read(rx_handle, buffer, audio_chunksize*sizeof(int16_t), &bytes_read, portMAX_DELAY);
ret = i2s_channel_read(rx_handle, buffer, audio_chunksize*sizeof(int16_t), &bytes_read, portMAX_DELAY);
//扩展到双声道
for(int i = audio_chunksize-1; i >=0; i--){
buffer[i*2] = buffer[i];
buffer[i*2+1] = 0;
}
return ret;
}
然后选择唤醒词,可以在menuconfig中选择。可以选择两个唤醒词,我这里使用的是:“小爱同学”,“小鸭小鸭”。命令字使用中文命令字,可以直接使用拼音输入命令字。这里给了5个命令字,用来对应控制LED灯的5个命令。使用消息队列进行传递。


void detect_Task(void *arg)
{
char command;
esp_afe_sr_data_t *afe_data = (esp_afe_sr_data_t *)arg;
int afe_chunksize = afe_handle->get_fetch_chunksize(afe_data);
char *mn_name = esp_srmodel_filter(models, ESP_MN_PREFIX, ESP_MN_CHINESE);
if (NULL == mn_name)
{
ESP_LOGE(VCTag, "No multinet model found");
return;
}
esp_mn_iface_t *multinet = esp_mn_handle_from_name(mn_name);
model_iface_data_t *model_data = multinet->create(mn_name, 6000); // 第二个参数6000 是指持续时间6秒
esp_mn_commands_clear();
esp_mn_commands_add(1, (char *)"qie huan xiao guo"); // 切换效果
esp_mn_commands_add(2, (char *)"tiao zheng liang du"); // 调整亮度
esp_mn_commands_add(2, (char *)"liang du tiao zheng"); // 亮度降低
esp_mn_commands_add(3, (char *)"xiu gai su du"); // 修改速度
esp_mn_commands_add(3, (char *)"jia kuai su du"); // 加快速度
esp_mn_commands_add(4, (char *)"xiu gai yan se"); // 修改颜色
esp_mn_commands_add(4, (char *)"gai bian yan se"); // 改变颜色
esp_mn_commands_add(5, (char *)"guan bi deng guang"); // 关闭灯光
esp_mn_commands_update();
multinet->print_active_speech_commands(model_data); // 打印当前正在使用的所有命令词条
int mu_chunksize = multinet->get_samp_chunksize(model_data);
assert(mu_chunksize == afe_chunksize);
while (task_flag)
{
afe_fetch_result_t *res = afe_handle->fetch(afe_data);
if (!res || res->ret_value == ESP_FAIL)
{
printf("fetch error!\n");
break;
}
if (res->wakeup_state == WAKENET_DETECTED)
{
ESP_LOGI(VCTag, "Wakeword detected ! model index:%d,word index:%d. Please speak:", res->wakenet_model_index, res->wake_word_index);
multinet->clean(model_data); // clean all status of multinet
}
if (res->raw_data_channels == 1 && res->wakeup_state == WAKENET_DETECTED)
{
detect_flag = 1;
}
else if (res->raw_data_channels > 1 && res->wakeup_state == WAKENET_CHANNEL_VERIFIED)
{
// For a multi-channel AFE, it is necessary to wait for the channel to be verified.
ESP_LOGI(VCTag, "AFE_FETCH_CHANNEL_VERIFIED, channel index: %d\n", res->trigger_channel_id);
detect_flag = 1;
}
if (detect_flag == 1)
{
esp_mn_state_t mn_state = multinet->detect(model_data, res->data);
if (mn_state == ESP_MN_STATE_DETECTING)
{
continue;
}
if (mn_state == ESP_MN_STATE_DETECTED)
{
esp_mn_results_t *mn_result = multinet->get_results(model_data);
for (int i = 0; i < mn_result->num; i++)
{
printf("TOP %d, command_id: %d, phrase_id: %d, string:%s prob: %f\n",
i + 1, mn_result->command_id[i], mn_result->phrase_id[i], mn_result->string, mn_result->prob[i]);
command = mn_result->command_id[i];
xQueueSend(xQueueCommand, &command, portMAX_DELAY);
}
printf("\n-----------listening-----------\n");
}
if (mn_state == ESP_MN_STATE_TIMEOUT)
{
esp_mn_results_t *mn_result = multinet->get_results(model_data);
ESP_LOGI(VCTag, "timeout, string:%s", mn_result->string);
afe_handle->enable_wakenet(afe_data);
detect_flag = 0;
printf("\n-----------awaits to be waken up-----------\n");
continue;
}
}
}
if (model_data)
{
multinet->destroy(model_data);
model_data = NULL;
}
vTaskDelete(NULL);
}
四:摄像头读取。做手势识别需要用到摄像头。这里参考着esp-who开源项目,进行读取摄像头。每读取一帧摄像头的图片,就通过消息队列传输给手势识别,进行识别。

五:手势识别。手势识别是使用乐鑫提供的esp-dl组件来实现的。esp-dl组件中提供了“hand_detect”和“hand_gesture_recognition”两个模型,前者用来侦测是否有手出现,后者用来识别手势。目前能识别的手势有“ok”,“one”,“two”,“three”,“four”,“five”。去掉“ok”手势,用剩下的5个手势对应控制LED灯的5个命令,使用消息队列传递。当摄像头通过消息队列传输来的每一帧图片,通过模型进行解析,识别到手势后,就产生命令字。
static void task_process_handler(void *arg)
{
camera_fb_t *frame = NULL;
char command;
dl::image::img_t img;
img.pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB565;
HandDetect *hand_detect = new HandDetect();
auto hand_gesture_recognizer = new HandGestureRecognizer(HandGestureCls::MOBILENETV2_0_5_S8_V1);
while (true)
{
if (xQueueReceive(xQueueFrameI, &frame, portMAX_DELAY))
{
img.data = frame->buf;
img.width = frame->width;
img.height = frame->height;
auto &hand_detect_results = hand_detect->run(img);
auto current_time = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::seconds>(current_time - last_detection_time).count() >= 1)
{
if (hand_detect_results.size() == 1)
{
std::vector<dl::cls::result_t> results = hand_gesture_recognizer->recognize(img, hand_detect_results); // 移除 & 符号
for (const auto &res : results)
{
ESP_LOGI(TAG, "category: %s, score: %f", res.cat_name, res.score);
// 判断 res.cat_name 是否为指定的手势名称
if (strcmp(res.cat_name, "one") == 0)
{
command = 1;
xQueueSend(xQueueCmdO, &command, portMAX_DELAY);
}else if (strcmp(res.cat_name, "two") == 0 ){
command = 2;
xQueueSend(xQueueCmdO, &command, portMAX_DELAY);
}else if (strcmp(res.cat_name, "three") == 0 ){
command = 3;
xQueueSend(xQueueCmdO, &command, portMAX_DELAY);
}else if (strcmp(res.cat_name, "four") == 0 ){
command = 4;
xQueueSend(xQueueCmdO, &command, portMAX_DELAY);
}else if (strcmp(res.cat_name, "five") == 0 ){
command = 5;
xQueueSend(xQueueCmdO, &command, portMAX_DELAY);
}
}
}
}
}
esp_camera_fb_return(frame);
}
}
void register_Dl_detection(const QueueHandle_t frame_i,
const QueueHandle_t frame_o)
{
xQueueFrameI = frame_i;
xQueueCmdO = frame_o;
xTaskCreatePinnedToCore(task_process_handler, "dl_process", 4 * 1024, NULL, 2, NULL, 0);
}
六:OLED显示。这里显示屏幕为一个128X64的单色OLED屏幕,连接到ESP32S3的I2C总线上,这里我使用lvgl组件来进行图像显示。将灯光效果、速度、亮度三个值分别做成三个标签,然后动态刷新屏幕。

七:灯光逻辑控制。因为灯光由多从因素控制,所以这里做了一下优先级排序。按键控制优先级最低,所以按键命令最先接收。然后是消息队列来的控制命令。这里的命令优先级最高。这样就可以解决命令字冲突问题。速度、亮度两个控制变量都采用单向循环控制,即只能减少,不能增加。当减少到0时,重新回到最大值,再次循环。颜色控制对彩虹灯无效。关闭灯光仅仅只是将亮度设置为0。每种灯光效果,让AI帮忙想出了牛逼点的描述,因为这里的lvgl使用的标签当字符多了的时候可以通过左右滚动来展示。
while (true)
{
command = ButtonCtl::GetInstance().key_scan(); // 按键处理
if(xQueueReceive(xQueueCmd, &command, 0)){
ESP_LOGI(TAG, "接收到命令字%d ", command);
}
switch (command)
{
case 1: // 按键1 切换效果 同时修改亮度到最大
{
LightEffect currEfct = strip.getEffect();
LightEffect nextEfct = (LightEffect)((currEfct + 1) % 5);
DisplayCtl::GetInstance().GetDisplay()->SetName(LightEffectName[nextEfct]);
strip.setEffect(nextEfct);
DisplayCtl::GetInstance().GetDisplay()->SetBright(strip.getBrightness());
break;
}
case 2: // 按键2 修改速度 速度增加5 速度不能小于0
{
int newSpeed=0;
if(strip.getSpeed() ==0 ) newSpeed = 50;
else newSpeed = (strip.getSpeed() > 5) ? (strip.getSpeed() - 5) : 0;
DisplayCtl::GetInstance().GetDisplay()->SetSpeed(newSpeed);
strip.setSpeed(newSpeed); // 设置新速度
break;
}
case 3: // 按键3 亮度
{
int newBright = (strip.getBrightness() >5) ? (strip.getBrightness() - 5) : 0;
DisplayCtl::GetInstance().GetDisplay()->SetBright(newBright);
strip.setBrightness(newBright); // 亮度
break;
}
case 4: // 按键4 修改颜色 同时亮度会调整到最大
{
strip.setColor();
DisplayCtl::GetInstance().GetDisplay()->SetBright(strip.getBrightness());
break;
}
case 5: // 按键5 关闭灯光
{
strip.setBrightness(0);
DisplayCtl::GetInstance().GetDisplay()->SetBright(0);
break;
}
default:
// 忽略无效按键或添加默认处理逻辑
break;
}
strip.update();
vTaskDelay(10 / portTICK_PERIOD_MS);
}
效果展示:





存在的问题:
问题1:ADC按键感觉很奇怪,似乎会漂移。每个按键设置了400的宽度,依然会存在启动后,按键ADC获取值不在设定范围内,不明白为啥。尤其按键1 从2000~2400都遇到过。
问题2:使用乐鑫官方提供的AI模型,很好用,效果很好。但是不知道为啥,手势识别和语音识别一起启动,手势识别就正常;语音识别中就只能唤醒,无法命令字识别。
问题3:LED灯的颜色问题。LED灯使用RGB构成具体的颜色,但是需要修改亮度,于是就将RGB转换为HSV模式,然后降低V的值,再转换回RGB模式,对纯色(R、G、B)还好,其它颜色,亮度一改变,颜色就跟着变化了。
问题4:速度问题。当开启了手势识别和语音识别。灯光效果控制速度就会被拖慢。但是语音识别因为有实时性,所以两个任务(监听任务、语音识别任务)优先级为最高,且分布在两个不同的核上。摄像头进程优先级次之,这样LED灯光的优先级就最低了,导致灯光跳动会卡顿。
心得体会:
感谢电子森林提供的这套“人工智能硬件实验套件平台”。既能体验AI编程的快了,又有小车玩。期待各位老师的作品,好期望实现平衡小车的功能。