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文件)。刷写过程很简单:

  1. 用USB线连接板子和电脑。
  2. 快速双击板子上的复位按钮,这时电脑上会出现一个名为 XIAO-SENSE 的U盘。
  3. 将下载好的 .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 计时器核心状态流转

一个标准的番茄钟包含两种主要状态:“工作”和“短休息”,完成多个番茄钟后会有“长休息”。我们需要用一个清晰的状态机来管理。我设计了以下几个状态:

  1. 空闲(IDLE) :初始状态,屏幕显示待机界面,等待语音指令。
  2. 工作中(WORKING) :25分钟倒计时开始。此时不应响应“开始”指令,但应响应“停止”或“暂停”。
  3. 暂停(PAUSED) :工作中途手动暂停。保留剩余时间,可恢复。
  4. 短休息(SHORT_BREAK) :5分钟倒计时。
  5. 长休息(LONG_BREAK) :15或20分钟倒计时,通常在完成4个番茄钟后触发。

状态之间的转换由事件驱动,事件来源包括:语音识别结果、物理按键(如暂停/继续)、以及计时器自身的超时信号。例如:

  • 语音识别到“开始” + 当前状态为IDLE -> 进入 WORKING 状态,启动25分钟倒计时。
  • WORKING状态倒计时归零 -> 进入 SHORT_BREAK 状态,启动5分钟倒计时,并播放提示音。
  • 语音识别到“停止” + 当前状态为WORKING或BREAK -> 取消当前计时,返回 IDLE 状态。

用代码实现时,可以定义一个全局状态变量和一个处理状态转换的函数。每次事件发生时,都根据当前状态和事件类型来决定下一个状态和要执行的动作(如启动新计时器、更新屏幕、播放声音)。

4.2 时间管理与显示更新

计时精度很重要,但CircuitPython本身不是实时操作系统, time.monotonic() 函数返回的是自开机以来的秒数(浮点数),精度足够我们做分钟级的倒计时。我的做法是:

  1. 在进入 WORKING 等计时状态时,记录开始时间戳: start_time = time.monotonic() ,并设定时长 duration = 25 * 60 (秒)。
  2. 在主循环中,计算已流逝时间: elapsed = time.monotonic() - start_time
  3. 剩余时间: remaining = max(duration - elapsed, 0)
  4. remaining 转换为分钟和秒,用于显示。
  5. remaining <= 0 时,触发状态转换事件。

显示更新需要平衡刷新频率和功耗。我们不需要每秒刷新60次。我的策略是:

  • 在倒计时阶段,每秒更新一次时间数字。
  • 进度条(一个逐渐填充或收缩的圆环)可以每5秒或10秒更新一次,视觉上足够平滑。
  • 在状态切换(如工作结束进入休息)时,立即重绘整个界面。

屏幕布局设计上,圆形屏幕中央显示大大的剩余时间(如“24:35”),外围用圆环进度条直观展示总时间的消耗比例。状态信息(如“工作中”、“休息中”)用较小的字体显示在顶部或底部。界面配色上,工作状态我用红色系(代表专注、紧张),休息状态用绿色系(代表放松)。

5. 用户交互优化与功能扩展

5.1 多模态反馈与误触发处理

一个好的交互设计不能只依赖单一通道。语音控制虽然方便,但必须有明确的多模态反馈,让用户知道设备“听到了”并且“理解了”。

  • 视觉反馈 :当麦克风检测到音量超过阈值(可能用户在说话)时,屏幕边缘可以显示一个跳动的小点。当关键词识别成功时,整个屏幕可以快速闪烁一下或改变颜色(如变亮再恢复)。
  • 听觉反馈 :识别成功时,通过蜂鸣器发出一个简短的、悦耳的确认音(例如一个高音“嘀”声)。计时结束时,播放一段不同的、更醒目的提示音(例如一段旋律)。
  • 触觉反馈 :如果有空间,加入一个微型振动马达会更好,但本项目为简化未加入。

误触发是语音交互的常见问题。除了前面提到的调整置信度阈值,还可以加入简单的逻辑屏蔽:

  • 静默期 :在一次识别成功后,设置一个1-2秒的静默期,在此期间忽略所有语音输入,防止同一指令被重复触发。
  • 上下文过滤 :例如,在“工作中”状态,忽略“开始”指令;在“休息中”状态,忽略“休息”指令。
  • 能量阈值 :在音频送入模型前,先计算其能量(音量),过低则直接丢弃,避免环境底噪被误识别。

5.2 高级功能与未来扩展思路

基础功能实现后,可以考虑一些增强功能,让这个小设备更智能:

  1. 番茄计数与统计 :在文件系统中创建一个简单的日志文件(如 log.csv )。每完成一个番茄钟,就记录下日期、开始时间、时长。CircuitPython可以读写 CIRCUITPY 盘上的文件。这样,你就能回顾自己的专注历史。
  2. Wi-Fi网络同步 :利用ESP32-S3的Wi-Fi功能,在每次番茄钟结束后,将数据通过HTTP POST发送到一个指定的服务器(如自己搭建的简易API,或云服务如Google Sheets)。这可以实现跨设备的数据汇总和分析。
  3. 个性化语音训练 :如果使用的语音识别框架支持,可以增加一个“训练模式”。长按物理按键进入该模式,然后让用户重复说几遍“开始”、“休息”等指令,设备在本地微调模型,从而更好地适应你的声音和口音,大幅提升识别率。
  4. 屏幕亮度自适应 :通过光敏电阻或某些开发板上的环境光传感器,自动调节屏幕亮度,夜间使用不刺眼,白天则清晰可见。
  5. 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。

  • 排查步骤
    1. 检查麦克风硬件 :先写一个简单的测试程序,将麦克风录制的原始数据通过串口打印出幅度,或者存成WAV文件在电脑上播放,确认麦克风本身是否工作、音量是否正常。
    2. 检查音频格式 :确认代码中设置的采样率(如16kHz)、位深度(如16-bit)与模型训练时使用的格式完全一致。不匹配是导致识别失败的常见原因。
    3. 检查模型加载 :确认模型文件路径正确,且文件没有损坏。尝试在模型初始化后打印一些简单信息,确保库被正确导入。
    4. 环境噪声 :在过于安静或嘈杂的环境下,识别效果都可能变差。可以尝试增加一个简单的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 语句)是你最好的朋友。将关键变量(如识别到的关键词、置信度、当前状态、剩余时间、空闲内存)打印出来,能帮你快速定位问题所在。当项目稳定后,再移除或禁用这些调试输出以提升性能。

更多推荐