【项目背景】
随着社会老龄化加剧和城镇化进程加快,独居老人与留守儿童的情感陪伴需求日益凸显。这类群体普遍面临沟通渠道单一、智能设备使用门槛高的问题,传统的通讯工具难以满足其情感交流和日常信息获取的需求。基于此,我们依托 FireBeetle 2 ESP32 S3 开发板,结合小智 AI 开源方案,设计并制作了一款操作简单、功能定制化的 AI 电话,通过实体按键和语音交互的方式,为特殊群体提供便捷的智能陪伴服务。
【项目核心目标】
你希望打造一款适配独居老人、留守儿童使用习惯的 AI 电话,核心实现:
- 基于 ESP32 S3 硬件平台,通过实体按键完成唤醒、退出、音量调节、天气播报、故事播放等定制化功能;
- 搭载 128*64 OLED 屏幕实现交互反馈,I2S 麦克风 + 喇叭完成语音采集与播放,支持实时 AI 聊天对话;
- 采用椴木板激光切割制作电话机型态,兼顾实用性与易用性,降低特殊群体的使用门槛。
【硬件选型与设计】
1. 核心硬件清单
2. 硬件接线逻辑(基于 config.h 配置)- 音频模块:采用 I2S Simplex 模式,麦克风对应 GPIO4(WS)、GPIO5(SCK)、GPIO6(DIN);喇叭对应 GPIO15(BCLK)、GPIO16(LRCK)、GPIO7(DOUT),保证语音采集与播放的稳定性;
- 显示模块:I2C 通信,SDA=GPIO1、SCL=GPIO2,支持 128*64 分辨率,镜像显示适配电话机型态安装;
- 按键模块:6 个按键分别映射 GPIO8(唤醒)、GPIO11(退出)、GPIO10(音量 +)、GPIO14(音量 -)、GPIO12(天气)、GPIO13(故事),均配置防抖处理;
- 其他:板载 LED(GPIO21)用于状态指示,预留 LAMP_GPIO(GPIO18)拓展接口。
面包板杜邦线测试
PCB简化接线
【主体框架】
【软件核心实现】
1. 开发环境基于 VSCode + ESP-IDF 开发框架,适配 ESP32 S3 的编译与烧录,核心依赖 WiFi 驱动、I2C/I2S 外设库、OLED 显示库、按键防抖库等。
2. 核心功能代码逻辑
(1)按键功能定制(compact_wifi_board.cc)
针对特殊群体的使用习惯,每个按键均做了状态适配处理,确保不同交互场景下的稳定性:
- 唤醒按键:空闲 / 收听 / 说话状态下均可触发,发送 “你好小智” 指令唤醒 AI,屏幕同步显示 “正在请求唤醒...”;
- 天气 / 故事按键:分别发送 “播报张家口天气”“播报故事” 指令,说话状态下会先中止当前音频再响应新请求;
- 退出按键:根据当前状态(收听 / 空闲 / 说话)适配处理,如说话状态下先停止播放,延迟发送退出指令;
- 音量按键:短按 ±10% 音量(0-100% 范围限制),长按直接拉满 / 静音,屏幕实时显示音量数值。
(2)音频与显示适配- 音频采样率:麦克风 16000Hz、喇叭 24000Hz,适配 AI 语音交互的音质需求;
- OLED 显示:集成中文字体(font_puhui_14_1),通过 ShowNotification 接口实时反馈操作状态,降低视觉理解门槛。
(3)状态管理
通过 DeviceState 枚举(Idle/Listening/Speaking/Connecting)管理设备状态,确保按键操作仅在有效状态下响应,避免指令冲突。
3. 代码核心优化点- 按键防抖:初始化时配置 2500ms 长按、1000ms 防抖阈值,适配老人操作时的按键长按 / 误触场景;
- 延迟指令:退出 / 唤醒等操作增加 500-800ms 延迟,避免状态切换过快导致的指令失效;
- 兼容性:支持 SSD1306/SH1106 两种 OLED 屏幕,通过宏定义切换,降低硬件适配成本。
【结构设计与制作】
- 外壳制作:采用椴木板激光切割,按照电话经典形态设计,听筒内置喇叭、话筒内置 I2S 麦克风,座机区域预留 6 个按键和 128*64 屏幕安装位;
- 人机工程:按键尺寸放大、间距增加,适配老人操作;屏幕位置置于座机顶部,视角清晰,文字显示放大;
- 布线优化:内部线路采用杜邦线 + 热缩管整理,避免松动,保证长期使用的稳定性。
【功能测试与场景适配】
1. 核心功能测试
2. 场景适配优化- 语音播报:AI 回复语速调慢,音量默认 50%,避免声音过大 / 过小;
- 操作简化:无复杂菜单,所有功能一键触发,无需文字输入;
- 稳定性:断网后自动重连,按键操作超时反馈,降低使用焦虑。
【项目价值与拓展】
1. 核心价值- 适配特殊群体:实体按键 + 语音交互,无需学习复杂操作,解决老人 / 儿童使用智能设备的门槛问题;
- 定制化服务:聚焦 “天气 + 故事 + 聊天” 核心需求,满足日常信息获取与情感陪伴;
- 开源可拓展:基于小智 AI 开源方案,可按需添加 “亲情通话”“紧急求助” 等功能。
2. 拓展方向- 增加语音提示:按键操作时增加 “音量增大”“正在播放故事” 等语音反馈,适配视力障碍群体;
- 远程管理:通过 WiFi 对接云端,子女 / 监护人可远程更新天气城市、故事列表;
- 低功耗优化:增加休眠模式,降低待机功耗,适配电池供电场景。
【演示视频】
1.小智AI电话机1.0
2.小智AI电话机2.0
【总结】
本项目基于 FireBeetle 2 ESP32 S3 和小智 AI 开源方案,通过硬件定制、软件适配、结构优化,打造了一款面向独居老人、留守儿童的 AI 电话。核心亮点:
- 易用性:实体按键 + 语音交互,无复杂操作,适配特殊群体使用习惯;
- 实用性:聚焦天气、故事、聊天核心需求,解决情感陪伴与信息获取痛点;
- 可拓展性:开源硬件 + 软件架构,支持功能迭代与硬件适配,具备落地推广潜力。
这款 AI 电话不仅是技术落地的实践,更是通过科技手段弥补情感陪伴缺口的尝试,为特殊群体搭建起与外界沟通的温暖桥梁。
【主要功能代码】
1、按钮功能实现
- touch_button_.OnClick([this]() {
- auto& app = Application::GetInstance();
- ESP_LOGI(TAG, "唤醒按钮点击");
-
- // 获取当前状态
- DeviceState current_state = app.GetDeviceState();
- ESP_LOGI(TAG, "当前状态: %d", current_state);
-
- // 只在特定状态下响应
- if (current_state == kDeviceStateIdle) {
- // 空闲状态直接发送
- std::string txt = "你好小智";
- ESP_LOGI(TAG, "发送唤醒请求: %s", txt.c_str());
-
- // 使用WakeWordInvoke
- app.WakeWordInvoke(txt);
-
- // 显示通知
- GetDisplay()->ShowNotification("正在请求唤醒...");
-
- } else if (current_state == kDeviceStateListening) {
- // 在收听状态下,直接发送请求,不要停止收听
- std::string txt = "你好小智";
- ESP_LOGI(TAG, "在收听状态下发送唤醒请求: %s", txt.c_str());
-
- // 直接通过Protocol发送,不改变状态
- app.GetProtocol().SendWakeWordDetected(txt);
-
- GetDisplay()->ShowNotification("正在请求唤醒...");
-
- } else if (current_state == kDeviceStateSpeaking) {
- // 说话状态下,中止当前说话并请求唤醒
- ESP_LOGI(TAG, "正在说话,中止并请求唤醒");
-
- // 使用WakeWordInvoke,它会处理中止和重新发送
- std::string txt = "你好小智";
- app.WakeWordInvoke(txt);
-
- GetDisplay()->ShowNotification("停止说话并请求唤醒...");
-
- } else {
- ESP_LOGI(TAG, "唤醒按钮忽略,当前状态: %d", current_state);
- GetDisplay()->ShowNotification("当前状态不支持");
- }
- });
- weather_button_.OnClick([this]() {
- auto& app = Application::GetInstance();
- ESP_LOGI(TAG, "天气按钮点击");
-
- // 获取当前状态
- DeviceState current_state = app.GetDeviceState();
- ESP_LOGI(TAG, "当前状态: %d", current_state);
-
- // 只在特定状态下响应
- if (current_state == kDeviceStateIdle) {
- // 空闲状态直接发送
- std::string txt = "播报张家口天气";
- ESP_LOGI(TAG, "发送天气请求: %s", txt.c_str());
-
- // 使用WakeWordInvoke
- app.WakeWordInvoke(txt);
-
- // 显示通知
- GetDisplay()->ShowNotification("正在请求播报天气...");
-
- } else if (current_state == kDeviceStateListening) {
- // 在收听状态下,直接发送请求,不要停止收听
- std::string txt = "播报张家口天气";
- ESP_LOGI(TAG, "在收听状态下发送播报天气请求: %s", txt.c_str());
-
- // 直接通过Protocol发送,不改变状态
- app.GetProtocol().SendWakeWordDetected(txt);
-
- GetDisplay()->ShowNotification("正在请求播报天气...");
-
- } else if (current_state == kDeviceStateSpeaking) {
- // 说话状态下,中止当前说话并请求播报天气
- ESP_LOGI(TAG, "正在说话,中止并请求播报天气");
-
- // 使用WakeWordInvoke,它会处理中止和重新发送
- std::string txt = "播报张家口天气";
- app.WakeWordInvoke(txt);
-
- GetDisplay()->ShowNotification("停止说话并请求播报天气...");
-
- } else {
- ESP_LOGI(TAG, "天气按钮忽略,当前状态: %d", current_state);
- GetDisplay()->ShowNotification("当前状态不支持");
- }
- });
- story_button_.OnClick([this]() {
- auto& app = Application::GetInstance();
- ESP_LOGI(TAG, "故事按钮点击");
-
- // 获取当前状态
- DeviceState current_state = app.GetDeviceState();
- ESP_LOGI(TAG, "当前状态: %d", current_state);
-
- // 只在特定状态下响应
- if (current_state == kDeviceStateIdle) {
- // 空闲状态直接发送
- std::string txt = "播报故事";
- ESP_LOGI(TAG, "发送故事请求: %s", txt.c_str());
-
- // 使用WakeWordInvoke
- app.WakeWordInvoke(txt);
-
- // 显示通知
- GetDisplay()->ShowNotification("正在请求故事...");
-
- } else if (current_state == kDeviceStateListening) {
- // 在收听状态下,直接发送请求,不要停止收听
- std::string txt = "播报故事";
- ESP_LOGI(TAG, "在收听状态下发送故事请求: %s", txt.c_str());
-
- // 直接通过Protocol发送,不改变状态
- app.GetProtocol().SendWakeWordDetected(txt);
-
- GetDisplay()->ShowNotification("正在请求故事...");
-
- } else if (current_state == kDeviceStateSpeaking) {
- // 说话状态下,中止当前说话并请求新故事
- ESP_LOGI(TAG, "正在说话,中止并请求新故事");
-
- // 使用WakeWordInvoke,它会处理中止和重新发送
- std::string txt = "播报故事";
- app.WakeWordInvoke(txt);
-
- GetDisplay()->ShowNotification("停止说话并请求新故事...");
-
- } else {
- ESP_LOGI(TAG, "故事按钮忽略,当前状态: %d", current_state);
- GetDisplay()->ShowNotification("当前状态不支持");
- }
- });
- exit_button_.OnClick([this]() {
- auto& app = Application::GetInstance();
- ESP_LOGI(TAG, "退出按钮点击");
-
- DeviceState current_state = app.GetDeviceState();
- ESP_LOGI(TAG, "当前状态: %d", current_state);
-
- // 退出请求的文本
- std::string exit_text = "退出";
-
- // 根据不同状态处理
- switch (current_state) {
- case kDeviceStateListening: {
- // 在收听状态下,直接发送退出请求
- ESP_LOGI(TAG, "发送退出请求: %s", exit_text.c_str());
-
- // 直接发送退出唤醒词
- app.GetProtocol().SendWakeWordDetected(exit_text);
-
- // 显示通知
- GetDisplay()->ShowNotification("正在退出...");
-
- // 不需要停止收听,让小智处理退出流程
- // 小智会回复"好的,再见"等,然后状态会自动变为空闲
- break;
- }
-
- case kDeviceStateIdle: {
- // 空闲状态下,也可以发送退出请求
- // 这可能会让小智说一些退出的话,比如"已退出对话"等
- ESP_LOGI(TAG, "空闲状态下发送退出请求");
-
- // 先打开音频通道
- app.ToggleChatState();
-
- // 延迟发送退出请求
- static esp_timer_handle_t exit_timer = nullptr;
- if (exit_timer) {
- esp_timer_stop(exit_timer);
- esp_timer_delete(exit_timer);
- }
-
- esp_timer_create_args_t timer_args = {
- .callback = [](void* arg) {
- auto data = static_cast<std::pair<Application*, std::string>*>(arg);
- if (data->first->GetProtocol().IsAudioChannelOpened()) {
- data->first->GetProtocol().SendWakeWordDetected(data->second);
- Board::GetInstance().GetDisplay()->ShowNotification("正在退出...");
- }
- delete data;
- },
- .arg = new std::pair<Application*, std::string>(&app, exit_text),
- .dispatch_method = ESP_TIMER_TASK,
- .name = "exit_timer"
- };
- esp_timer_create(&timer_args, &exit_timer);
- esp_timer_start_once(exit_timer, 500000); // 500ms延迟
- break;
- }
-
- case kDeviceStateSpeaking: {
- // 说话状态下,先停止说话,再退出
- ESP_LOGI(TAG, "正在说话,中止并退出");
-
- // 先中止当前说话
- app.ToggleChatState(); // 这会中止说话
-
- // 延迟发送退出请求
- static esp_timer_handle_t speaking_exit_timer = nullptr;
- if (speaking_exit_timer) {
- esp_timer_stop(speaking_exit_timer);
- esp_timer_delete(speaking_exit_timer);
- }
-
- esp_timer_create_args_t timer_args = {
- .callback = [](void* arg) {
- auto data = static_cast<std::pair<Application*, std::string>*>(arg);
- DeviceState state = data->first->GetDeviceState();
-
- if (state == kDeviceStateIdle) {
- // 如果已变为空闲状态,直接显示已退出
- Board::GetInstance().GetDisplay()->ShowNotification("已退出");
- } else if (state == kDeviceStateListening) {
- // 如果变为收听状态,发送退出请求
- data->first->GetProtocol().SendWakeWordDetected(data->second);
- Board::GetInstance().GetDisplay()->ShowNotification("正在退出...");
- }
- delete data;
- },
- .arg = new std::pair<Application*, std::string>(&app, exit_text),
- .dispatch_method = ESP_TIMER_TASK,
- .name = "speaking_exit_timer"
- };
- esp_timer_create(&timer_args, &speaking_exit_timer);
- esp_timer_start_once(speaking_exit_timer, 800000); // 800ms延迟
- break;
- }
-
- case kDeviceStateConnecting: {
- // 连接状态下,直接关闭连接
- ESP_LOGI(TAG, "正在连接,直接关闭");
- app.ToggleChatState(); // 这会关闭连接
- GetDisplay()->ShowNotification("连接已取消");
- break;
- }
-
- default: {
- // 其他状态不支持退出
- ESP_LOGI(TAG, "退出按钮忽略,当前状态: %d", current_state);
- GetDisplay()->ShowNotification("当前状态无法退出");
- break;
- }
- }
- });
- volume_up_button_.OnClick([this]() {
- auto codec = GetAudioCodec();
- auto volume = codec->output_volume() + 10;
- if (volume > 100) {
- volume = 100;
- }
- codec->SetOutputVolume(volume);
- GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
- });
-
- volume_up_button_.OnLongPress([this]() {
- GetAudioCodec()->SetOutputVolume(100);
- GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME);
- });
-
- volume_down_button_.OnClick([this]() {
- auto codec = GetAudioCodec();
- auto volume = codec->output_volume() - 10;
- if (volume < 0) {
- volume = 0;
- }
- codec->SetOutputVolume(volume);
- GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
- });
-
- volume_down_button_.OnLongPress([this]() {
- GetAudioCodec()->SetOutputVolume(0);
- GetDisplay()->ShowNotification(Lang::Strings::MUTED);
- });
复制代码 2.按键对象初始化:为6 个按键(唤醒键、音量 ±、退出、天气、故事)配置 GPIO 引脚和防抖 / 长按参数;
- CompactWifiBoard() :
- boot_button_(BOOT_BUTTON_GPIO),
- touch_button_(TOUCH_BUTTON_GPIO, true,2500, 1000),
- volume_up_button_(VOLUME_UP_BUTTON_GPIO, true,2500, 1000),
- volume_down_button_(VOLUME_DOWN_BUTTON_GPIO, true, 2500, 1000),
- exit_button_(EXIT_BUTTON_GPIO, true, 2500, 1000),
- weather_button_(WEATHER_BUTTON_GPIO, true, 2500, 1000),
- story_button_(STORY_BUTTON_GPIO, true, 2500, 1000){
- InitializeDisplayI2c();
- InitializeSsd1306Display();
- InitializeButtons();
- InitializeTools();
- }
复制代码
|