1. 项目概述:一个能“听懂话”的番茄钟
最近在捣鼓一个桌面小玩意儿,起因很简单:我发现自己用手机上的番茄钟App时,总忍不住顺手刷两下别的,所谓的“专注”时间反而成了“分心”的开端。于是就想,能不能做个物理上独立、操作更直觉的番茄钟?正好手头有块Seeed Studio的XIAO ESP32S3 Sense开发板,它集成了麦克风和圆形显示屏,一个想法就蹦出来了——做一个能用语音控制的番茄钟计时器,我给它起名叫AskLou.io。
这个项目的核心,就是让一块硬件脱离手机,独立完成番茄工作法的计时任务。你不需要去点屏幕上的按钮,直接对着它说“开始一个25分钟的番茄钟”或者“休息5分钟”,它就能听懂并执行。XIAO ESP32S3 Sense板载的麦克风负责拾音,ESP32-S3芯片运行语音识别模型,结果通过那块小巧的圆形LCD显示出来,时间流逝用进度条或者数字变化来呈现,一目了然。整个开发过程我选择了CircuitPython,因为它对硬件外设的驱动和网络功能封装得极好,用Python写起来快,调试也方便,特别适合这种软硬件结合的原型开发。
做这个东西,适合两类朋友:一是想找个有趣项目入门物联网和嵌入式AI的开发者,你能接触到语音唤醒、关键词识别、硬件UI绘制等一整套流程;二是像我一样,受困于数字干扰,想打造一个极简、高效的实体生产力工具的用户。它摆在你桌上,就是一个专注的象征,通过最自然的语音交互,帮你守住那25分钟的心流时间。
2. 硬件选型与核心组件解析
2.1 为什么是XIAO ESP32S3 Sense?
选择这块板子作为核心,是经过一番考量的。市面上能跑AI模型的MCU不少,但XIAO ESP32S3 Sense在尺寸、功能和易用性上达到了一个很好的平衡点。
首先看核心芯片ESP32-S3。它是一颗双核Xtensa LX7处理器,主频高达240MHz,最关键的是内置了向量指令集,能够加速神经网络计算。对于我们这个需要实时进行语音关键词识别的应用来说,本地计算的低延迟至关重要。你总不想说完指令后等上两三秒才有反应,那体验就毁了。ESP32-S3的性能足以在本地流畅运行一个轻量化的语音识别模型,无需连接云端,既保护了隐私,也保证了响应速度。
其次是集成的硬件。板子自带一个数字麦克风(PDM),这省去了外接麦克风模块的麻烦,电路更简洁,拾音效果也经过原厂调试。显示部分,我搭配了Seeed专门为XIAO系列设计的1.28英寸圆形LCD屏。这块屏通过SPI接口驱动,分辨率是240x240,显示个计时界面、进度条绰绰有余,圆形的外观也比方屏更有设计感。板载的锂电池充电管理电路是另一个亮点,这意味着你可以用一块常见的3.7V锂电池供电,让整个设备完全无线化,随意摆在书桌的任何角落。
最后是生态。Seeed为这块板子提供了完善的CircuitPython固件和支持库,像显示驱动、麦克风读取、Wi-Fi等功能都有现成的adafruit或seeed系列库可用,极大降低了开发门槛。你不需要从零开始写底层驱动,可以把精力集中在应用逻辑和交互设计上。
2.2 外围电路与供电设计
虽然核心板功能强大,但要成为一个独立的桌面设备,还需要考虑供电和交互。我的方案是使用一块容量在500mAh到1000mAh之间的软包锂电池。这个容量足以保证设备在中等亮度下连续工作一整天以上。充电则通过XIAO ESP32S3 Sense板载的Type-C接口完成,非常方便。
注意:在选择锂电池时,务必确认其带有保护板。这能防止电池过充、过放和短路,是安全使用的基本保障。不要为了省几块钱而使用“光板”电芯。
为了提升交互体验,我额外增加了一个物理按键和一个蜂鸣器。按键用于强制复位、切换模式或在语音识别不理想时作为备用输入。蜂鸣器则用于提供听觉反馈,例如番茄钟开始、结束时的提示音。这些元件都非常简单,按键接在某个GPIO上并启用内部上拉电阻,蜂鸣器则接在另一个GPIO上通过PWM驱动。整个系统的结构非常清晰:电池供电给XIAO主板,主板驱动屏幕、麦克风、按键和蜂鸣器,形成一个完整的交互闭环。
3. 软件开发环境与核心库搭建
3.1 CircuitPython固件刷写与基础环境
第一步是让开发板跑起CircuitPython。你需要从CircuitPython官网下载针对Seeed Studio XIAO ESP32S3 Sense的最新版本固件(.uf2文件)。刷写过程很简单:
- 用USB线连接板子和电脑。
- 快速双击板子上的复位按钮,这时电脑上会出现一个名为
XIAO-SENSE的U盘。 - 将下载好的
.uf2文件拖入这个U盘。U盘会自动弹出,板子重启后就会进入CircuitPython环境。
完成后,电脑上会出现一个新的名为CIRCUITPY的U盘,这就是板子的文件系统。你的所有代码和库文件都将放在这里。接下来,需要安装必要的库。打开CircuitPython的库捆绑包(Bundle),找到并拷贝以下库文件到CIRCUITPY盘的lib文件夹下:
adafruit_bus_device:基础总线设备支持。adafruit_display_text和adafruit_display_shapes:用于在屏幕上显示文本和图形(如圆形进度条)。adafruit_imageload:如果需要显示位图图标。seeed-studio或adafruit_esp32s3tft中对应你屏幕型号的驱动库(例如seeed_xiao_round_display)。adafruit_pioasm:某些高级功能可能需要。- 最重要的是语音识别相关的库。这里我使用了
EloquentTinyML库的一个简化版本,或者使用Espressif官方提供的esp-sr在CircuitPython上的移植库,用于关键词识别(Keyword Spotting, KWS)。
实操心得:管理
lib文件夹时,最好只拷贝项目必需的库。CircuitPython的设备内存有限,过多的库文件可能导致内存不足,运行时出现MemoryError。如果遇到奇怪的内存错误,首先检查lib目录是否过于臃肿。
3.2 语音识别引擎的选择与集成
本地语音识别是本项目的技术核心。对于“开始”、“休息”、“停止”这样的简单指令,我们不需要复杂的连续语音识别,关键词识别(KWS)就足够了。KWS模型体积小、计算量低,非常适合在ESP32-S3上运行。
我测试了两种方案。一种是使用Espressif官方MFCC+神经网络方案,它需要先将模型转换为TensorFlow Lite Micro格式,然后集成到CircuitPython中。这个过程对新手有些复杂,但识别效率和准确率很高。另一种是使用现成的、更简单的库,比如针对几个特定关键词训练的轻量模型。对于原型开发,我建议先从后者开始。
例如,你可以使用一个预先训练好的、能识别“Start”、“Break”、“Stop”三个英文关键词的模型文件(通常是一个.tflite或.bin文件)。将这个模型文件放入CIRCUITPY盘。在代码中,你需要初始化麦克风,持续采集音频数据(例如以16kHz采样率),然后按帧(比如每1秒)送入模型进行推理。模型会输出一个得分数组,对应每个关键词的置信度。当某个关键词的置信度超过阈值(如0.7),就认为识别成功,触发相应的计时器动作。
# 伪代码示例:语音识别循环 import audiobusio import board from your_kws_library import KeywordSpotter # 初始化麦克风 mic = audiobusio.PDMIn(board.MICROPHONE_CLOCK, board.MICROPHONE_DATA, sample_rate=16000, bit_depth=16) kws = KeywordSpotter(model_path="/model.kws") audio_buffer = bytearray(32000) # 2秒的音频缓冲区 while True: mic.record(audio_buffer, len(audio_buffer)) # 录音 keyword, confidence = kws.predict(audio_buffer) # 预测 if confidence > 0.7: if keyword == "start": start_pomodoro(25*60) # 开始25分钟番茄钟 elif keyword == "break": start_timer(5*60) # 开始5分钟休息 # ... 其他关键词处理这个过程的关键在于调整音频采样参数和模型推理的阈值,需要在识别率和误触发率之间找到平衡。在安静环境下,阈值可以设低一些以提高灵敏度;在嘈杂环境下,则需要提高阈值以避免误触发。
4. 番茄钟计时逻辑与状态机设计
4.1 计时器核心状态流转
一个标准的番茄钟包含两种主要状态:“工作”和“短休息”,完成多个番茄钟后会有“长休息”。我们需要用一个清晰的状态机来管理。我设计了以下几个状态:
- 空闲(IDLE):初始状态,屏幕显示待机界面,等待语音指令。
- 工作中(WORKING):25分钟倒计时开始。此时不应响应“开始”指令,但应响应“停止”或“暂停”。
- 暂停(PAUSED):工作中途手动暂停。保留剩余时间,可恢复。
- 短休息(SHORT_BREAK):5分钟倒计时。
- 长休息(LONG_BREAK):15或20分钟倒计时,通常在完成4个番茄钟后触发。
状态之间的转换由事件驱动,事件来源包括:语音识别结果、物理按键(如暂停/继续)、以及计时器自身的超时信号。例如:
语音识别到“开始”+当前状态为IDLE-> 进入WORKING状态,启动25分钟倒计时。WORKING状态倒计时归零-> 进入SHORT_BREAK状态,启动5分钟倒计时,并播放提示音。语音识别到“停止”+当前状态为WORKING或BREAK-> 取消当前计时,返回IDLE状态。
用代码实现时,可以定义一个全局状态变量和一个处理状态转换的函数。每次事件发生时,都根据当前状态和事件类型来决定下一个状态和要执行的动作(如启动新计时器、更新屏幕、播放声音)。
4.2 时间管理与显示更新
计时精度很重要,但CircuitPython本身不是实时操作系统,time.monotonic()函数返回的是自开机以来的秒数(浮点数),精度足够我们做分钟级的倒计时。我的做法是:
- 在进入
WORKING等计时状态时,记录开始时间戳:start_time = time.monotonic(),并设定时长duration = 25 * 60(秒)。 - 在主循环中,计算已流逝时间:
elapsed = time.monotonic() - start_time。 - 剩余时间:
remaining = max(duration - elapsed, 0)。 - 将
remaining转换为分钟和秒,用于显示。 - 当
remaining <= 0时,触发状态转换事件。
显示更新需要平衡刷新频率和功耗。我们不需要每秒刷新60次。我的策略是:
- 在倒计时阶段,每秒更新一次时间数字。
- 进度条(一个逐渐填充或收缩的圆环)可以每5秒或10秒更新一次,视觉上足够平滑。
- 在状态切换(如工作结束进入休息)时,立即重绘整个界面。
屏幕布局设计上,圆形屏幕中央显示大大的剩余时间(如“24:35”),外围用圆环进度条直观展示总时间的消耗比例。状态信息(如“工作中”、“休息中”)用较小的字体显示在顶部或底部。界面配色上,工作状态我用红色系(代表专注、紧张),休息状态用绿色系(代表放松)。
5. 用户交互优化与功能扩展
5.1 多模态反馈与误触发处理
一个好的交互设计不能只依赖单一通道。语音控制虽然方便,但必须有明确的多模态反馈,让用户知道设备“听到了”并且“理解了”。
- 视觉反馈:当麦克风检测到音量超过阈值(可能用户在说话)时,屏幕边缘可以显示一个跳动的小点。当关键词识别成功时,整个屏幕可以快速闪烁一下或改变颜色(如变亮再恢复)。
- 听觉反馈:识别成功时,通过蜂鸣器发出一个简短的、悦耳的确认音(例如一个高音“嘀”声)。计时结束时,播放一段不同的、更醒目的提示音(例如一段旋律)。
- 触觉反馈:如果有空间,加入一个微型振动马达会更好,但本项目为简化未加入。
误触发是语音交互的常见问题。除了前面提到的调整置信度阈值,还可以加入简单的逻辑屏蔽:
- 静默期:在一次识别成功后,设置一个1-2秒的静默期,在此期间忽略所有语音输入,防止同一指令被重复触发。
- 上下文过滤:例如,在“工作中”状态,忽略“开始”指令;在“休息中”状态,忽略“休息”指令。
- 能量阈值:在音频送入模型前,先计算其能量(音量),过低则直接丢弃,避免环境底噪被误识别。
5.2 高级功能与未来扩展思路
基础功能实现后,可以考虑一些增强功能,让这个小设备更智能:
- 番茄计数与统计:在文件系统中创建一个简单的日志文件(如
log.csv)。每完成一个番茄钟,就记录下日期、开始时间、时长。CircuitPython可以读写CIRCUITPY盘上的文件。这样,你就能回顾自己的专注历史。 - Wi-Fi网络同步:利用ESP32-S3的Wi-Fi功能,在每次番茄钟结束后,将数据通过HTTP POST发送到一个指定的服务器(如自己搭建的简易API,或云服务如Google Sheets)。这可以实现跨设备的数据汇总和分析。
- 个性化语音训练:如果使用的语音识别框架支持,可以增加一个“训练模式”。长按物理按键进入该模式,然后让用户重复说几遍“开始”、“休息”等指令,设备在本地微调模型,从而更好地适应你的声音和口音,大幅提升识别率。
- 屏幕亮度自适应:通过光敏电阻或某些开发板上的环境光传感器,自动调节屏幕亮度,夜间使用不刺眼,白天则清晰可见。
- OTA无线更新:通过Wi-Fi实现固件和代码的无线更新,这样修复bug或增加新功能时,就不必再插拔USB线了。
这些扩展功能会涉及更复杂的编程,如网络请求、文件操作、更复杂的状态管理,但它们展示了从一个小原型演进为一个真正实用产品的完整路径。
6. 系统集成、调试与问题排查
6.1 代码模块化与主循环结构
当所有功能都开发完成后,需要将它们整合到一个稳定、高效的主循环中。我的代码结构大致如下:
# main.py 结构概览 import time import board import displayio from audiobusio import PDMIn # ... 导入其他必要的库 # 1. 初始化硬件 display = init_display() mic = init_microphone() buzzer = init_buzzer() button = init_button() # 2. 初始化状态机和计时器 current_state = State.IDLE pomodoro_count = 0 timer_start_time = 0 timer_duration = 0 # 3. 加载语音识别模型 kws = load_kws_model() # 4. 主显示组 main_group = displayio.Group() # ... 创建并添加文本、图形对象到main_group display.show(main_group) # 5. 主循环 last_display_update = 0 audio_buffer = bytearray(16000 * 2) # 2秒的缓冲区 while True: now = time.monotonic() # A. 处理物理按键(去抖动后) if button_pressed(): handle_button_event(current_state) # B. 处理语音输入(非阻塞式) if mic.available() >= len(audio_buffer): mic.record(audio_buffer, len(audio_buffer)) keyword, conf = kws.predict(audio_buffer) if conf > THRESHOLD: current_state = handle_voice_event(current_state, keyword) # C. 更新计时器逻辑 if current_state in [State.WORKING, State.SHORT_BREAK, State.LONG_BREAK]: remaining = timer_duration - (now - timer_start_time) if remaining <= 0: # 计时结束,触发状态转换和提示音 current_state = handle_timer_finish(current_state) play_sound(buzzer, SOUND_FINISH) # 按需更新显示(例如每秒一次) if now - last_display_update >= 1.0: update_display(main_group, current_state, remaining) last_display_update = now # D. 短暂休眠以降低功耗(非必须,但有益) time.sleep(0.01) # 10ms这个循环确保了按键响应、语音监听、计时更新和屏幕刷新都能得到及时处理,同时又通过微小休眠降低了CPU占用率。
6.2 常见问题与调试技巧实录
在开发过程中,我遇到了不少坑,这里记录下最典型的几个及其解决方法:
问题1:语音识别完全没反应,或者置信度始终为0。
- 排查步骤:
- 检查麦克风硬件:先写一个简单的测试程序,将麦克风录制的原始数据通过串口打印出幅度,或者存成WAV文件在电脑上播放,确认麦克风本身是否工作、音量是否正常。
- 检查音频格式:确认代码中设置的采样率(如16kHz)、位深度(如16-bit)与模型训练时使用的格式完全一致。不匹配是导致识别失败的常见原因。
- 检查模型加载:确认模型文件路径正确,且文件没有损坏。尝试在模型初始化后打印一些简单信息,确保库被正确导入。
- 环境噪声:在过于安静或嘈杂的环境下,识别效果都可能变差。可以尝试增加一个简单的VAD(语音活动检测),只在检测到人声时才将音频送入模型。
问题2:屏幕闪烁、花屏,或者更新后残留上一帧图像。
- 原因与解决:这通常是显示刷新逻辑问题。在CircuitPython的
displayio中,当你修改了显示组(Group)中的元素(如文本的.text属性、形状的位置)后,需要“刷新”显示。- 确保所有对显示对象的修改都在主循环中进行。
- 对于频繁更新的文本,考虑使用
label.text = f"{min:02d}:{sec:02d}"这样的方式直接更新,而不是先移除再添加。 - 如果使用了多个Group进行页面切换,在切换时确保正确调用
display.show(new_group),并等待显示刷新完成(可以加一个短暂的time.sleep(0.05))。
- 内存不足:如果画面复杂,更新时可能因内存不足而失败。尝试简化图形,或使用
displayio.release_displays()在切换页面时释放资源(需谨慎)。
问题3:设备运行一段时间后死机或重启。
- 首要怀疑对象:内存泄漏。CircuitPython有垃圾回收,但如果你在循环中不断创建新的对象(如新的显示对象、新的数组),而没有及时解除引用,内存会被慢慢耗尽。
- 检查方法:在循环中定期打印
gc.mem_free(),观察可用内存是否持续下降。 - 解决方法:将需要重复使用的对象(如音频缓冲区、显示元素)在循环外一次性创建好,在循环内只修改其内容,而非重新创建。
- 检查方法:在循环中定期打印
- 电源问题:如果使用电池供电,在电机(如振动马达)启动或屏幕背光全亮时,可能产生瞬时电压跌落,导致MCU复位。可以在电源输入端并联一个大电容(如100µF)来缓冲。
问题4:按键响应不灵或连击。
- 软件去抖动:这是必须的。最简单的做法是在检测到按键按下后,延时50ms再读取一次引脚状态,如果仍然是按下,才认为是有效按键。
def debounced_button_press(pin): if not pin.value: # 假设按下为低电平 time.sleep(0.05) # 延时50ms if not pin.value: # 再次确认 return True return False - 硬件去抖动:如果软件效果不佳,可以在按键引脚和地之间并联一个0.1µF的电容。
问题5:电池续航远低于预期。
- 屏幕背光是耗电大户:尝试降低屏幕亮度。很多屏幕驱动库支持
brightness属性,将其设置为0.3到0.5通常就能在室内看清,同时大幅省电。 - 优化主循环:在循环末尾增加一个
time.sleep(0.05)甚至更长,可以显著降低CPU占用率。但要确保睡眠时间不会影响语音监听的实时性(可以将音频采集放在睡眠前,或使用中断)。 - 关闭未用外设:如果暂时不用Wi-Fi,确保其已断开连接并进入睡眠模式。
最后,调试嵌入式项目,串口打印(print语句)是你最好的朋友。将关键变量(如识别到的关键词、置信度、当前状态、剩余时间、空闲内存)打印出来,能帮你快速定位问题所在。当项目稳定后,再移除或禁用这些调试输出以提升性能。