2025-12-23 20:53:05 [显示全部楼层]
9浏览
查看: 9|回复: 0

[项目] 小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁

[复制链接]
本帖最后由 云天 于 2025-12-23 20:58 编辑

【项目背景】
       随着社会老龄化加剧和城镇化进程加快,独居老人与留守儿童的情感陪伴需求日益凸显。这类群体普遍面临沟通渠道单一、智能设备使用门槛高的问题,传统的通讯工具难以满足其情感交流和日常信息获取的需求。基于此,我们依托 FireBeetle 2 ESP32 S3 开发板,结合小智 AI 开源方案,设计并制作了一款操作简单、功能定制化的 AI 电话,通过实体按键和语音交互的方式,为特殊群体提供便捷的智能陪伴服务。
【项目核心目标】
       你希望打造一款适配独居老人、留守儿童使用习惯的 AI 电话,核心实现:
  • 基于 ESP32 S3 硬件平台,通过实体按键完成唤醒、退出、音量调节、天气播报、故事播放等定制化功能;
  • 搭载 128*64 OLED 屏幕实现交互反馈,I2S 麦克风 + 喇叭完成语音采集与播放,支持实时 AI 聊天对话;
  • 采用椴木板激光切割制作电话机型态,兼顾实用性与易用性,降低特殊群体的使用门槛。
【硬件选型与设计】
       1. 核心硬件清单
小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图1

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图3

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图4


       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)拓展接口。
小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图5

面包板杜邦线测试

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图7

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图6

PCB简化接线

【主体框架】

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图9

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图8

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图12

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图13

小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图11


【软件核心实现】
       1. 开发环境基于 VSCode + ESP-IDF 开发框架,适配 ESP32 S3 的编译与烧录,核心依赖 WiFi 驱动、I2C/I2S 外设库、OLED 显示库、按键防抖库等。
小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图10

       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. 核心功能测试
小智 AI 电话:为独居老人搭建温暖的语音陪伴桥梁图2

       2. 场景适配优化
  • 语音播报:AI 回复语速调慢,音量默认 50%,避免声音过大 / 过小;
  • 操作简化:无复杂菜单,所有功能一键触发,无需文字输入;
  • 稳定性:断网后自动重连,按键操作超时反馈,降低使用焦虑。
【项目价值与拓展】
       1. 核心价值
  • 适配特殊群体:实体按键 + 语音交互,无需学习复杂操作,解决老人 / 儿童使用智能设备的门槛问题;
  • 定制化服务:聚焦 “天气 + 故事 + 聊天” 核心需求,满足日常信息获取与情感陪伴;
  • 开源可拓展:基于小智 AI 开源方案,可按需添加 “亲情通话”“紧急求助” 等功能。
       2. 拓展方向
  • 增加语音提示:按键操作时增加 “音量增大”“正在播放故事” 等语音反馈,适配视力障碍群体;
  • 远程管理:通过 WiFi 对接云端,子女 / 监护人可远程更新天气城市、故事列表;
  • 低功耗优化:增加休眠模式,降低待机功耗,适配电池供电场景。
【演示视频】
       1.小智AI电话机1.0

       2.小智AI电话机2.0

【总结】
       本项目基于 FireBeetle 2 ESP32 S3 和小智 AI 开源方案,通过硬件定制、软件适配、结构优化,打造了一款面向独居老人、留守儿童的 AI 电话。核心亮点:
  • 易用性:实体按键 + 语音交互,无复杂操作,适配特殊群体使用习惯;
  • 实用性:聚焦天气、故事、聊天核心需求,解决情感陪伴与信息获取痛点;
  • 可拓展性:开源硬件 + 软件架构,支持功能迭代与硬件适配,具备落地推广潜力。
这款 AI 电话不仅是技术落地的实践,更是通过科技手段弥补情感陪伴缺口的尝试,为特殊群体搭建起与外界沟通的温暖桥梁。
【主要功能代码】
1、按钮功能实现
  1. touch_button_.OnClick([this]() {
  2.     auto& app = Application::GetInstance();
  3.     ESP_LOGI(TAG, "唤醒按钮点击");
  4.    
  5.     // 获取当前状态
  6.     DeviceState current_state = app.GetDeviceState();
  7.     ESP_LOGI(TAG, "当前状态: %d", current_state);
  8.    
  9.     // 只在特定状态下响应
  10.     if (current_state == kDeviceStateIdle) {
  11.         // 空闲状态直接发送
  12.         std::string txt = "你好小智";
  13.         ESP_LOGI(TAG, "发送唤醒请求: %s", txt.c_str());
  14.         
  15.         // 使用WakeWordInvoke
  16.         app.WakeWordInvoke(txt);
  17.         
  18.         // 显示通知
  19.         GetDisplay()->ShowNotification("正在请求唤醒...");
  20.         
  21.     } else if (current_state == kDeviceStateListening) {
  22.         // 在收听状态下,直接发送请求,不要停止收听
  23.         std::string txt = "你好小智";
  24.         ESP_LOGI(TAG, "在收听状态下发送唤醒请求: %s", txt.c_str());
  25.         
  26.         // 直接通过Protocol发送,不改变状态
  27.         app.GetProtocol().SendWakeWordDetected(txt);
  28.         
  29.         GetDisplay()->ShowNotification("正在请求唤醒...");
  30.         
  31.     } else if (current_state == kDeviceStateSpeaking) {
  32.         // 说话状态下,中止当前说话并请求唤醒
  33.         ESP_LOGI(TAG, "正在说话,中止并请求唤醒");
  34.         
  35.         // 使用WakeWordInvoke,它会处理中止和重新发送
  36.         std::string txt = "你好小智";
  37.         app.WakeWordInvoke(txt);
  38.         
  39.         GetDisplay()->ShowNotification("停止说话并请求唤醒...");
  40.         
  41.     } else {
  42.         ESP_LOGI(TAG, "唤醒按钮忽略,当前状态: %d", current_state);
  43.         GetDisplay()->ShowNotification("当前状态不支持");
  44.     }
  45. });      
  46. weather_button_.OnClick([this]() {
  47.     auto& app = Application::GetInstance();
  48.     ESP_LOGI(TAG, "天气按钮点击");
  49.    
  50.     // 获取当前状态
  51.     DeviceState current_state = app.GetDeviceState();
  52.     ESP_LOGI(TAG, "当前状态: %d", current_state);
  53.    
  54.     // 只在特定状态下响应
  55.     if (current_state == kDeviceStateIdle) {
  56.         // 空闲状态直接发送
  57.         std::string txt = "播报张家口天气";
  58.         ESP_LOGI(TAG, "发送天气请求: %s", txt.c_str());
  59.         
  60.         // 使用WakeWordInvoke
  61.         app.WakeWordInvoke(txt);
  62.         
  63.         // 显示通知
  64.         GetDisplay()->ShowNotification("正在请求播报天气...");
  65.         
  66.     } else if (current_state == kDeviceStateListening) {
  67.         // 在收听状态下,直接发送请求,不要停止收听
  68.         std::string txt = "播报张家口天气";
  69.         ESP_LOGI(TAG, "在收听状态下发送播报天气请求: %s", txt.c_str());
  70.         
  71.         // 直接通过Protocol发送,不改变状态
  72.         app.GetProtocol().SendWakeWordDetected(txt);
  73.         
  74.         GetDisplay()->ShowNotification("正在请求播报天气...");
  75.         
  76.     } else if (current_state == kDeviceStateSpeaking) {
  77.         // 说话状态下,中止当前说话并请求播报天气
  78.         ESP_LOGI(TAG, "正在说话,中止并请求播报天气");
  79.         
  80.         // 使用WakeWordInvoke,它会处理中止和重新发送
  81.         std::string txt = "播报张家口天气";
  82.         app.WakeWordInvoke(txt);
  83.         
  84.         GetDisplay()->ShowNotification("停止说话并请求播报天气...");
  85.         
  86.     } else {
  87.         ESP_LOGI(TAG, "天气按钮忽略,当前状态: %d", current_state);
  88.         GetDisplay()->ShowNotification("当前状态不支持");
  89.     }
  90. });
  91. story_button_.OnClick([this]() {
  92.     auto& app = Application::GetInstance();
  93.     ESP_LOGI(TAG, "故事按钮点击");
  94.    
  95.     // 获取当前状态
  96.     DeviceState current_state = app.GetDeviceState();
  97.     ESP_LOGI(TAG, "当前状态: %d", current_state);
  98.    
  99.     // 只在特定状态下响应
  100.     if (current_state == kDeviceStateIdle) {
  101.         // 空闲状态直接发送
  102.         std::string txt = "播报故事";
  103.         ESP_LOGI(TAG, "发送故事请求: %s", txt.c_str());
  104.         
  105.         // 使用WakeWordInvoke
  106.         app.WakeWordInvoke(txt);
  107.         
  108.         // 显示通知
  109.         GetDisplay()->ShowNotification("正在请求故事...");
  110.         
  111.     } else if (current_state == kDeviceStateListening) {
  112.         // 在收听状态下,直接发送请求,不要停止收听
  113.         std::string txt = "播报故事";
  114.         ESP_LOGI(TAG, "在收听状态下发送故事请求: %s", txt.c_str());
  115.         
  116.         // 直接通过Protocol发送,不改变状态
  117.         app.GetProtocol().SendWakeWordDetected(txt);
  118.         
  119.         GetDisplay()->ShowNotification("正在请求故事...");
  120.         
  121.     } else if (current_state == kDeviceStateSpeaking) {
  122.         // 说话状态下,中止当前说话并请求新故事
  123.         ESP_LOGI(TAG, "正在说话,中止并请求新故事");
  124.         
  125.         // 使用WakeWordInvoke,它会处理中止和重新发送
  126.         std::string txt = "播报故事";
  127.         app.WakeWordInvoke(txt);
  128.         
  129.         GetDisplay()->ShowNotification("停止说话并请求新故事...");
  130.         
  131.     } else {
  132.         ESP_LOGI(TAG, "故事按钮忽略,当前状态: %d", current_state);
  133.         GetDisplay()->ShowNotification("当前状态不支持");
  134.     }
  135. });
  136. exit_button_.OnClick([this]() {
  137.     auto& app = Application::GetInstance();
  138.     ESP_LOGI(TAG, "退出按钮点击");
  139.    
  140.     DeviceState current_state = app.GetDeviceState();
  141.     ESP_LOGI(TAG, "当前状态: %d", current_state);
  142.    
  143.     // 退出请求的文本
  144.     std::string exit_text = "退出";
  145.    
  146.     // 根据不同状态处理
  147.     switch (current_state) {
  148.         case kDeviceStateListening: {
  149.             // 在收听状态下,直接发送退出请求
  150.             ESP_LOGI(TAG, "发送退出请求: %s", exit_text.c_str());
  151.             
  152.             // 直接发送退出唤醒词
  153.             app.GetProtocol().SendWakeWordDetected(exit_text);
  154.             
  155.             // 显示通知
  156.             GetDisplay()->ShowNotification("正在退出...");
  157.             
  158.             // 不需要停止收听,让小智处理退出流程
  159.             // 小智会回复"好的,再见"等,然后状态会自动变为空闲
  160.             break;
  161.         }
  162.         
  163.         case kDeviceStateIdle: {
  164.             // 空闲状态下,也可以发送退出请求
  165.             // 这可能会让小智说一些退出的话,比如"已退出对话"等
  166.             ESP_LOGI(TAG, "空闲状态下发送退出请求");
  167.             
  168.             // 先打开音频通道
  169.             app.ToggleChatState();
  170.             
  171.             // 延迟发送退出请求
  172.             static esp_timer_handle_t exit_timer = nullptr;
  173.             if (exit_timer) {
  174.                 esp_timer_stop(exit_timer);
  175.                 esp_timer_delete(exit_timer);
  176.             }
  177.             
  178.             esp_timer_create_args_t timer_args = {
  179.                 .callback = [](void* arg) {
  180.                     auto data = static_cast<std::pair<Application*, std::string>*>(arg);
  181.                     if (data->first->GetProtocol().IsAudioChannelOpened()) {
  182.                         data->first->GetProtocol().SendWakeWordDetected(data->second);
  183.                         Board::GetInstance().GetDisplay()->ShowNotification("正在退出...");
  184.                     }
  185.                     delete data;
  186.                 },
  187.                 .arg = new std::pair<Application*, std::string>(&app, exit_text),
  188.                 .dispatch_method = ESP_TIMER_TASK,
  189.                 .name = "exit_timer"
  190.             };
  191.             esp_timer_create(&timer_args, &exit_timer);
  192.             esp_timer_start_once(exit_timer, 500000); // 500ms延迟
  193.             break;
  194.         }
  195.         
  196.         case kDeviceStateSpeaking: {
  197.             // 说话状态下,先停止说话,再退出
  198.             ESP_LOGI(TAG, "正在说话,中止并退出");
  199.             
  200.             // 先中止当前说话
  201.             app.ToggleChatState(); // 这会中止说话
  202.             
  203.             // 延迟发送退出请求
  204.             static esp_timer_handle_t speaking_exit_timer = nullptr;
  205.             if (speaking_exit_timer) {
  206.                 esp_timer_stop(speaking_exit_timer);
  207.                 esp_timer_delete(speaking_exit_timer);
  208.             }
  209.             
  210.             esp_timer_create_args_t timer_args = {
  211.                 .callback = [](void* arg) {
  212.                     auto data = static_cast<std::pair<Application*, std::string>*>(arg);
  213.                     DeviceState state = data->first->GetDeviceState();
  214.                     
  215.                     if (state == kDeviceStateIdle) {
  216.                         // 如果已变为空闲状态,直接显示已退出
  217.                         Board::GetInstance().GetDisplay()->ShowNotification("已退出");
  218.                     } else if (state == kDeviceStateListening) {
  219.                         // 如果变为收听状态,发送退出请求
  220.                         data->first->GetProtocol().SendWakeWordDetected(data->second);
  221.                         Board::GetInstance().GetDisplay()->ShowNotification("正在退出...");
  222.                     }
  223.                     delete data;
  224.                 },
  225.                 .arg = new std::pair<Application*, std::string>(&app, exit_text),
  226.                 .dispatch_method = ESP_TIMER_TASK,
  227.                 .name = "speaking_exit_timer"
  228.             };
  229.             esp_timer_create(&timer_args, &speaking_exit_timer);
  230.             esp_timer_start_once(speaking_exit_timer, 800000); // 800ms延迟
  231.             break;
  232.         }
  233.         
  234.         case kDeviceStateConnecting: {
  235.             // 连接状态下,直接关闭连接
  236.             ESP_LOGI(TAG, "正在连接,直接关闭");
  237.             app.ToggleChatState(); // 这会关闭连接
  238.             GetDisplay()->ShowNotification("连接已取消");
  239.             break;
  240.         }
  241.         
  242.         default: {
  243.             // 其他状态不支持退出
  244.             ESP_LOGI(TAG, "退出按钮忽略,当前状态: %d", current_state);
  245.             GetDisplay()->ShowNotification("当前状态无法退出");
  246.             break;
  247.         }
  248.     }
  249. });
  250.         volume_up_button_.OnClick([this]() {
  251.             auto codec = GetAudioCodec();
  252.             auto volume = codec->output_volume() + 10;
  253.             if (volume > 100) {
  254.                 volume = 100;
  255.             }
  256.             codec->SetOutputVolume(volume);
  257.             GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
  258.         });
  259.         volume_up_button_.OnLongPress([this]() {
  260.             GetAudioCodec()->SetOutputVolume(100);
  261.             GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME);
  262.         });
  263.         volume_down_button_.OnClick([this]() {
  264.             auto codec = GetAudioCodec();
  265.             auto volume = codec->output_volume() - 10;
  266.             if (volume < 0) {
  267.                 volume = 0;
  268.             }
  269.             codec->SetOutputVolume(volume);
  270.             GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
  271.         });
  272.         volume_down_button_.OnLongPress([this]() {
  273.             GetAudioCodec()->SetOutputVolume(0);
  274.             GetDisplay()->ShowNotification(Lang::Strings::MUTED);
  275.         });
复制代码
2.按键对象初始化:为6 个按键(唤醒键、音量 ±、退出、天气、故事)配置 GPIO 引脚和防抖 / 长按参数;

  1. CompactWifiBoard() :
  2.         boot_button_(BOOT_BUTTON_GPIO),
  3.         touch_button_(TOUCH_BUTTON_GPIO, true,2500, 1000),
  4.         volume_up_button_(VOLUME_UP_BUTTON_GPIO, true,2500, 1000),
  5.         volume_down_button_(VOLUME_DOWN_BUTTON_GPIO, true, 2500, 1000),
  6.         exit_button_(EXIT_BUTTON_GPIO, true, 2500, 1000),
  7.         weather_button_(WEATHER_BUTTON_GPIO, true, 2500, 1000),
  8.         story_button_(STORY_BUTTON_GPIO, true, 2500, 1000){
  9.         InitializeDisplayI2c();
  10.         InitializeSsd1306Display();
  11.         InitializeButtons();
  12.         InitializeTools();
  13.     }
复制代码







您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

为本项目制作心愿单
购买心愿单
心愿单 编辑
[[wsData.name]]

硬件清单

  • [[d.name]]
btnicon
我也要做!
点击进入购买页面
上海智位机器人股份有限公司 沪ICP备09038501号-4 备案 沪公网安备31011502402448

© 2013-2025 Comsenz Inc. Powered by Discuz! X3.4 Licensed

mail