本帖最后由 云天 于 2025-12-23 20:58 编辑
【项目背景】
随着社会老龄化加剧和城镇化进程加快,独居老人与留守儿童的情感陪伴需求日益凸显。这类群体普遍面临沟通渠道单一、智能设备使用门槛高的问题,传统的通讯工具难以满足其情感交流和日常信息获取的需求。基于此,我们依托 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();
} 复制代码