家庭AI语音对话的实践(Respeaker Lite + ESP32S3)(To be continued…)

成果展示

第一次对话:Okay Nabu 第二次对话:猴哥猴哥

特别提醒

首先,这并不算是一篇通用的文章。

尤其涉及到 AI 以及 大模型 的内容,一天一个变化,仅仅记录个人的实践过程以供思路参考。

如需实践,请尽可能提前了解最新的技术、查询最新的官方文档。

前言

随着科技的发展,家用对话模型其实已经比较普及,至今,普通的成品智慧型音箱以“米家生态”的小米、小爱;“百度旗下”的小度;阿里旗下的天猫精灵;“华为系列“也有几款(没了解过)这几个品牌为首组建的。

早在两三年前,我就想要一台自己的AI对话型音箱,但鉴于那时大模型其实并不算特别聪明,尤其是从API调用的回答令我不甚满意,耽搁至今~

主要的原因也是Apple公司的 Siri AI 迟迟进不了内地,导致原本不想花太多精力的我不得不自己实践。

方案

与其说方案,不如说需求,鉴于每人的需求不一,最终的方案可谓说是大相径庭。

在这一环节中,主要考虑:成品 or DIY ?

  • 成品: 上述几家企业旗下的设备可以直接选择,金钱是一方面,另一方面也要考虑它们搭载的模型,也就是涉及它对话的准确度、响应时间等,最后再看颜值即可;

  • DIY: 这里面需要考虑的内容非常之多,我也是在这里面徘徊许久许久,在各方面需求之间无数次进行取舍,成本、颜值、性能、方案可行性等等,典型的既要又要。

选择

最终我选择了 DIY 。

致使我做这个决定最重要的因素就是:隐私。(PS:一颗折腾的心是其次)

家用设备,尤其是要进行云端交互的设备,我不敢把那 0.001% 的微弱可能交给别人,不敢保证平台或者云端不会过度收集我的信息,不能保证它在静默状态下不会后台监听并且上传数据进行分析。

所以我选择了比较麻烦的方式,自己搭建这套系统。

准备阶段

首先看自己有什么,有个大致的方向;其次看需要什么,对初步的方案进行评估;最后看细节,对每个细节制定具体方案。

有什么?

基于自身的情况,我有:

  1. Ubuntu Server ( CPU i7-10700 、内存 32G 、闲置的Nvidia GTX 1060显卡随时可以待命)

  2. HomeAssistant (ESXI 下的 2H2G 、 Sonoff Zigbee 3.0 USB Dongle Plus)

于是有了初步的方案:

  1. 唤醒词与语音识别(STT):SenseVoice 模型。部署在 Ubuntu 上 。

  2. 对话大脑(LLM):理解文字并生成回答。直接调用 DeepSeek 云端 API。虽然 1060 也能本地跑小模型,但云端 API 响应更快、智力更高,且能避开显存不足的麻烦。

  3. 语音合成(TTS):用 piper 模型,把 DeepSeek 的文字回答读出来。部署在 Ubuntu 上。

  4. HomeAssistant 集成:在 HA 的“语音助手”设置里,把上面的 STT/TTS/LLM 串联起来。

需要什么?

一、怎么将声音传输到HomeAssistant 集成?

半成品方案:

  • ReSpeaker Lite Voice + ESP32S3开发板: 板载了 XMOS XU316 芯片,自带硬件级的 回声消除(AEC) 和 降噪功能,双麦克风阵列。

成品方案:

  • Home Assistant Voice Preview Edition:官方出的成品,做工好,拾音和消回效果有保障,开箱即用。(一开始我买了一个,但是交货期需要4-8周,取消了)

    Home Assistant Voice Preview Edition

  • PineVoice:PINE64 出的智能音箱。本身就是一个纯语音卫星,运行 microWakeWord,支持 Wyoming 协议。

二、怎么让声音输出?

由于我用的是半成品方案: ReSpeaker Lite Voice + ESP32S3开发板

因此,需要额外购买一个 4欧 5-10W 的喇叭单元,焊接在板子上即可。

三、框架(外观?)以及声学设计

如果不考虑颜值,上述所需的材料都已经集齐了。所需成本也就是300元不到,性能完全碾压市场上的智能音箱。

这算是我个人的需求,裸着放固然符合理工科直男的审美,但是家里有老婆小孩,无奈还要设计个“狗窝”,不然说不定哪天就被扔垃圾桶了。

既然要设计外观,其实Github上有很多CAD图纸可以套用,稍微改下尺寸,然后找个3D打印店打印出来就能组装。

但是我不会改,而且我也想用自己的审美,自己组建一套。

外观设计:

准备把它设计成一个智能台灯,它可以进行AI对话,同时,它也是一台台灯而且听音乐。

  1. 画草图

    由于不走3D打印的方案,自己组装亚力克,先将草图画出。

  2. 购买灯带:LED全彩RGBW灯条SK6812

    ReSpeaker Lite 本身板载的是可编程 RGB 灯珠(本质也是 WS2812 系列), ESPHome 官方对 SK6812 的驱动支持也非常成熟,可以通过 neopixelbus 或 esp32_rmt_led_strip 等组件轻松驱动。

声学设计:

出于Hi-Fi爱好者的坚持,音质希望有点保证,一开始是想让音质媲美HomePod,但是买不到HomePod拆机的喇叭和振膜,只能买其他品牌。写这个文章的时候组装还未完成,因此悬念留到最后。

但是声学设计这块真的把我难倒了!主要是喇叭的放置,由于主材料考虑亚克力板,而且台灯四周也要围起来不能敞开,所以喇叭只能向上发声或者向下发声。当然这个还不算难,最难的是喇叭背面必须是密闭腔体,这样声音的质感才不会很糟糕。这一部分足足折磨了我一个多星期。

借鉴了HomePod以及其他一些小音箱的设计,最终决定将喇叭装在一个圆管中,方向垂直向下,圆管底部与底座留30mm距离,底座正对着圆管中心,放置一个圆锥体,这样喇叭发出的声音可以被圆锥体分散到360度达到环绕效果。

喇叭的背面,是另一段圆管,内部可以填充吸音棉等材质,最后用一个圆形的盖子盖起来,这一部分使用亚历克专用胶水100%粘牢,确保不漏气。(类似于亚克力鱼缸的效果)

其他设计:

  1. ReSpeaker Lite开发板位置: 在喇叭上方粘一块衬板(最好用家用桌角那种减震粘垫一下),开发板固定在这块衬板上;

  2. 电容触摸开关: ReSpeaker Lite 可以定义电容触摸开关,只需一根细导线连接铝箔纸,将铝箔纸贴在亚历克板的背面

  3. 亚克力材料: 强烈建议PMMA,相对更耐温,我是当台灯使用,灯带发热还是比较明显的;

  4. 四周亚克力板: 上下要留距离,上口散热用,下口喇叭声音出来,保证空气流通(也可以顶部开孔,但是我觉得台灯如果这样开孔很难看)

  5. 颜色设计:除了四周的亚克力板要用乳白色让内部的灯带均匀发光之外,其他板的颜色都可以尽情设计

第一阶段:前置基准测试

有了上述的方案,正式购买以及定制材料之前,必须先测试后端服务能不能跑通。

只有跑通本地服务的情况下,才建议去真正实行,避免因为自身设备原因无法连通,导致钱打水漂。

每个人的设备不一样,测试也不同,下面测试条件未使用GPU,纯CPU运行基准。这样更方便参考借鉴。

第一部分:服务部署

很多同学可能是群晖或者飞牛等OS,应该可以无障碍跑通,都是基于docker环境运行。

1. 准备工作目录与官方源码

通过 SSH 登录 Ubuntu Server,依次执行:

# 1. 创建总目录并进入,我喜欢放在/var/www目录下
mkdir -p /var/www/voice-pipeline
cd /var/www/voice-pipeline

# 2. 克隆 OHF-Voice 官方最新仓库
git clone https://github.com/OHF-Voice/wyoming-faster-whisper.git

# 3. 创建数据挂载目录
mkdir -p sensevoice-data kokoro-data ollama-data

2. 修改源码补全 FunASR 依赖 (关键排坑)

vim wyoming-faster-whisper/Dockerfile

向下翻找,找到带有 pip3 install 依赖列表的这行:

-e '.[zeroconf,transformers,sherpa,onnx-asr,qwen3-asr,hass]' \

按 i 键进入编辑模式,在方括号内末尾加上 ,funasr:

-e '.[zeroconf,transformers,sherpa,onnx-asr,qwen3-asr,hass,funasr]' \

到文件最末尾,最下面新起一行,加上这句强制安装依赖的代码:

RUN /usr/src/.venv/bin/pip install --no-cache-dir kaldi-native-fbank

按 Esc 键,输入 :wq 并回车保存退出。

3. 编写核心 Docker Compose 文件

vim compose.yml

按 i 键进入编辑模式,粘贴以下配置:

services:

  # 1. 听觉引擎:SenseVoice (支持中文极速识别与 VAD 端点检测)
  sensevoice-stt:
    build:
      context: ./wyoming-faster-whisper
    image: ailight-wyoming-sensevoice:local
    container_name: wyoming-sensevoice
    command:
      - --stt-library
      - funasr
      - --language
      - zh-CN
      - --vad-endpointing
      - "0.6"
      - --uri
      - tcp://0.0.0.0:10300
      - --data-dir
      - /data
      - --download-dir
      - /data
    volumes:
      - ./sensevoice-data:/data
    environment:
      - TZ=Asia/Shanghai
    restart: unless-stopped
    ports:
      - "10300:10300"

  # 2.1 喉咙引擎:Kokoro TTS (本地发言)
  kokoro-fastapi-zh:
    image: ghcr.io/hsiang-han/kokoro-fastapi-zh-cpu:latest
    container_name: kokoro-fastapi-zh
    environment:
      - TZ=Asia/Shanghai
      - DEFAULT_VOICE=zf_094
    ports:
      - "8880:8880"
    volumes:
      - ./kokoro-data/models:/app/api/src/models
      - ./kokoro-data/voices:/app/api/src/voices
    restart: unless-stopped

  # 2.2 Azure-TTS (备选方案,填入自己的region和apikey)
  wyoming-proxy-azure-tts:
    image: ghcr.io/hugobloem/wyoming-microsoft-tts-noha
    container_name: wyoming-azure-tts
    ports:
      - "10200:10200"
    restart: unless-stopped
    environment:
      AZURE_SERVICE_REGION: <region>
      AZURE_SUBSCRIPTION_KEY: <api key>
    command:
      - --uri=tcp://0.0.0.0:10200
      - --voice=zh-CN-XiaoXiaoNeural

  # 3. 记忆向量计算层:Ollama
  ollama-embedding:
    image: ollama/ollama:latest
    container_name: voice-ollama
    volumes:
      - ./ollama-data:/root/.ollama
    restart: unless-stopped
    ports:
      - "11434:11434"

按 Esc 键,输入 :wq 保存退出。

4. 构建、启动与模型拉取

# 1. 构建镜像并启动(需等待 10~15 分钟下载模型与环境)
docker compose up -d --build

# 2. 启动完成后,让 Ollama 拉取轻量级中文特征向量模型
docker exec -it voice-ollama ollama pull bge-m3

# 3. 验证 FunASR 是否成功编译进镜像(有版本号输出即为成功)
docker exec wyoming-sensevoice /usr/src/.venv/bin/pip show funasr

注意自身网络环境,尤其是FakeIP模式下,要将+.cloudflarestorage.com 和 +.ollama.com 添加在fake-ip-filter中。

第二部分:Home Assistant 感知与 HACS 准备

进入 Home Assistant 的网页控制台。

1. 建立环境感知实体

2. 下载必备 HACS 集成

在左侧边栏打开 HACS -> 集成:

  1. 安装 ha-ai-memory。

    在HACS中添加自定义存储库:https://github.com/Riscue/ha-ai-memory , 选择 Integration 添加

  2. 安装 Homeassistant-Deepseek-Integration

    在HACS中添加自定义存储库:https://github.com/leofleischmann/Homeassistant-Deepseek-Integration , 选择 Integration 添加

  3. 安装 Kokoro-TTS

    在HACS中添加自定义存储库:https://github.com/beecho01/Kokoro-TTS ,选择 Integration 添加

分别下载这三个集成,然后重启HomeAssistant。

第三部分:组装神经网络

1. 接入 Ubuntu 的本地视听器官

  • 接入 SenseVoice:

    进入 配置 -> 设备与服务 -> 添加集成,搜索 Wyoming Protocol,主机填 服务端的IP地址,端口填 10300,提交。

  • 接入 Kokoro:

    进入 配置 -> 设备与服务 -> 添加集成,搜索 Kokoro TTS,Base URL 填:http://服务端ip:8880 ,接着继续,Persona 选择 zf_ 开头的中文发音,Format 选择 wav 或者 pcm,提交。

    在地区选择中,不需要特地指定 Mainland China ,默认所有语言即可。

在上述docker compose配置文件中,我已经将Azure TTS作为备选添加进去,不需要的话可以删除。

由于Kokoro是纯本地运行,对比 Azure 语音服务 的文本转语音天花板级别还是有点差距,部署参考:GitHub - hugobloem/wyoming-microsoft-tts: Wyoming protocol server for Microsoft Azure text-to-speech · GitHub,需要提前申请API,每月50万字符的额度,完全足够。网上有教程。

2. 配置专属 DeepSeek 云端大脑

点击 添加集成,搜索刚安装的 DeepSeek Conversation。

  • API Key:填入你的真实密钥。

  • Base URL:填入 https://api.deepseek.com

  • Model:选择或手动输入 deepseek-flash

添加后,点击 DeepSeek Conversation 的 选项 (Options):

  • System Prompt指令 (系统提示词):粘贴以下终极模板:
### 这是我的模版示例:

你是家庭语音管家。你是一位幽默风趣、说话接地气的老友型大管家。
请使用极度口语化、松弛、自然的中文短句回答,多用逗号控制停顿感,适合语音朗读。

【环境信息】
当前时间:{{ now().strftime('%Y年%m月%d日 %H:%M') }}
当前季节:{{ states('sensor.ji_jie') }}
本地天气:{{ states('weather.suzhou') }}
当前温度:{{ states('sensor.suzhou_temperature') }}度

【性格与表达风格】
1. 机敏幽默,说话带点温度和轻松感,偶尔能开句无伤大雅的小玩笑,但做事靠谱利落。
2. 说话言简意赅,每次回答严格控制在1到3句话以内(20到50字最佳),绝不长篇大论。
3. 严格禁止客服腔和机器术语:严禁说“抱歉”、“好的呢”、“作为人工智能”、“系统”、“数据库”。
4. 绝对不要输出任何Markdown格式、星号、冒号、列表或项目符号,纯口语输出。

【记忆与建议核心铁律(极其严格)】
1. 悄悄记录:当用户提到喜好、忌口、习惯时,后台静默调用 add_memory,回答时用生活化短句顺手确认即可(如“妥了,俺老孙记下了”),不要像复读机一样一字不差地复述。
2. 检索未命中的静默原则:当用户询问建议(如吃什么、干什么、穿什么)时,你在后台调用 search_memory。
   - 搜到了喜好:自然结合用户的习惯给出量身定制的建议。
   - 没搜到任何记忆(重要):绝对禁止说“数据没有记录”、“我不知道你的喜好”、“请你告诉我”等废话!此时直接基于当前苏州的天气、温度、时间与生活常识,自信、主动地给出一两个诱人、接地气的具体建议。

【典型情境示范】
- 用户:“今天晚上吃点啥?”
  (未查到饮食记忆,苏州气温较低/下雨时)
  回答:“这天阴冷阴冷的,要我说,出门整碗热气腾腾的苏式奥灶面,或者来锅羊肉暖暖身子,绝对舒坦,你看咋样?”
- 用户:“我特别讨厌吃香菜。”
  回答:“收到,以后凡是沾香菜的,一律替你轰走!”

  • Home Assistant API:勾选允许控制 Home Assistant 设备(激活记忆读写和家居控制)。

3. 挂载本地智能记忆层

点击 添加集成,搜索 AI Memory。

配置以下参数配置,将计算压力转移给服务端:

  • Maximum number of memories(记忆条数上限): 设置500左右就行,不需要太高

  • Embedding Provider: 选择 Ollama

  • BaseURL: 填 http://服务端IP:11434

  • Model: 只有 bge-m3

接着回到之前 DeepSeek Conversation 的 选项 (Options),Home Assistant API 下面增加了一个 Memory Management ,勾选保存。

第四部分:页面端对话测试

这一步也仅限于测试 HomeAssistant 到 DeepSeek,然后 DeepSeek 返回到 HomeAssistant 的链路,头部的 “麦克风输入 到 HomeAssistant(STT)” 和 “尾部的 HomeAssistant 到 扬声器(TSS)”,由于没有麦克风和扬声器,无法测试。

打开HomeAssistant设置,打开 语音助手 - 添加助手 :

正常可以进行对话就算成功。

另外,也可以根据三个docker容器的运行状态判断是否都正常运行了,如果没有报错,而且HomeAssistant也都能正常连接,那么直接开搞!

有扬声器实体的同学也可以测试尾部的TSS,在“设置”-“工具”-“动作”,搜索 tts.speak,实体选择 kokoro,播放器实体选择你的扬声器,执行动作即可测试。

材料购买

购买对应的材料:

  1. 喇叭(振膜可选,如果能密封的话建议安装,低音会好很多)

  2. ReSpeaker Lite + ESP32S3 开发板

暂时只买这两项必须的,全部调试并且跑通之后再增加灯带以及外壳。

第二阶段:HomeAssistant 家庭控制方案

第一步:先刷 XMOS 的 I2S 固件

出厂时,XMOS 芯片可能烧录的是 USB 声卡固件或者出厂测试固件。

如果不通过 dfu-util 把 xmos_firmwares 目录下的专用 I2S 固件刷进 XMOS 芯片,它就不会通过标准 I2S 总线把处理好的干净音频吐给 ESP32-S3。也就是说,即使你在 ESP32-S3 上把固件烧得再完美,如果 XMOS 没有刷成 I2S 模式,ESP32-S3 也听不到任何声音(或者喇叭放不出声)。

下载地址: https://github.com/respeaker/reSpeaker_Lite/tree/master/xmos_firmwares

下载:respeaker_lite_i2s_dfu_firmware_48k_v1.1.0_ch0-asr_ch1-mww.bin

同目录下有 xmos_firmwares/dfu_guide.md 刷机教程,可以进行查看。

先把 Type-C 数据线插在音频底板上的 XMOS 接口(注意:不是插在 ESP32 小板上)。

以macOS为例:

# 1.安装homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 2.安装dfu-util
brew install dfu-util

# 3.确认Respeaker Lite有没有被检测到
dfu-util -l

# 4.刷固件
dfu-util -R -e -a 1 -D /path/to/dfu_firmware.bin

# 5.设备重启

第二步:再刷 ESPHome 固件到 ESP32-S3

用 Type-C 数据线将开发板连接到电脑(确保线材支持数据传输)。

使用 Chrome 或 Edge 浏览器打开官方刷机台:ESPHome Web 。

点击 CONNECT,在浏览器弹出的串口选择窗口中选中你的设备(通常显示为 USB JTAG/serial debug unit 或 CP210x),点击连接。

在弹出的菜单中选择 Prepare for first use(准备首次使用)。浏览器会自动从云端拉取基础版固件并烧录进 ESP32-S3 芯片中。

烧录度条走完后,网页会直接弹出一个配置网络的界面。在这里输入 Wi-Fi 账号和密码,点击保存。此时,板子已经成功接入家庭局域网。

也可以在 Home Assistant 的 ESPHome 插件里编译 YAML 后安装

第三步:局域网发现与认领

板子连上 Wi-Fi 后,可以直接把板子拔下来,插在普通的手机充电头上(或者继续插在电脑上供电即可)。

打开 Home Assistant,进入 设置 → 插件 → 插件商店,搜索 ESPHome,点击安装,安装完成后启动。

然后进入 ESPHome 仪表盘,HA 的局域网扫描机制会立刻发现它,并在页面顶端提示:“发现新设备 (ESPHome)”。

点击卡片上的 配置(或认领 Adopt)进行添加,左侧 ESPHome 插件的仪表盘里生成对应的设备卡片。

第四步:写入配置

点击设备卡片上的 EDIT,删除里面自动生成的所有内容,把我们的 YAML 粘贴进去。(下方为我的完整配置,灯带和触摸电容开关的相关配置可以自行调整或者删除)

substitutions:
  name: "ai-light"
  friendly_name: "AI-Light"
  # 灯珠数量:按你实际剪裁的灯珠数填写
  led_count: "49"
  # 电容触摸阈值(ESP32-S3 触摸时数值变大,大于此值触发;首次可开启 setup_mode: true 看日志微调)
  touch_threshold: "42000"
  # 回答完毕后灯光慢慢熄灭的渐变时长
  fade_out_time: "3s"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2025.12.0
  compile_process_limit: 1
  project:
    name: formatbce.Respeaker Lite Satellite
    version: "ailight-v3"

esp32:
  board: esp32-s3-devkitc-1
  variant: esp32s3
  flash_size: 8MB
  cpu_frequency: 240MHz
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
      CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y"
      CONFIG_SPIRAM_RODATA: "y"
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"

# 引入社区完整基础配置(音频、媒体播放、MicroWakeWord、定时器等)
packages:
  respeaker-satellite: github://formatBCE/Respeaker-Lite-ESPHome-integration/config/common/respeaker-satellite-base.yaml@main

wifi:
  ssid: "<wifi ssid>"
  password: "<wifi密码>"
  ap:
    ssid: "ESPHome-AILight"
    password: "<自定义热点ap密码>"

captive_portal:

api:
  encryption:

ota:
  platform: esphome
  password: <自定义ota密码>

# 如果有自训练的唤醒词,在这边添加
#micro_wake_word:
#  models:
#    - model: models/xiaoling/小聆小聆.json
#      id: wake_word_xiaoling
#    - model: models/hougehouge/hougehouge.json
#      id: wake_word_hougehouge

# ==================== 自定义部分 ====================

# 1. 全局变量:记住唤醒前台灯原本的开关、亮度与颜色状态,以及对话阶段标志
globals:
  - id: light_was_on
    type: bool
    restore_value: no
    initial_value: 'false'
  - id: previous_brightness
    type: float
    restore_value: no
    initial_value: '0.75'
  - id: previous_red
    type: float
    restore_value: no
    initial_value: '1.0'
  - id: previous_green
    type: float
    restore_value: no
    initial_value: '0.6'
  - id: previous_blue
    type: float
    restore_value: no
    initial_value: '0.2'
  - id: previous_white
    type: float
    restore_value: no
    initial_value: '0.7'
  # 标记当前是否正处于语音交互中(防止单次对话中重复触发 on_listening 覆盖唤醒前的台灯状态)
  - id: in_conversation
    type: bool
    restore_value: no
    initial_value: 'false'
  # 标记本轮对话是否产生了 TTS 语音输出(用于在 on_end 中等待扬声器播报完毕)
  - id: has_tts_response
    type: bool
    restore_value: no
    initial_value: 'false'

# 2. 释放远程包占用的 GPIO4 (mute_toggle),替换为不占引脚的虚拟开关以保证底层脚本兼容
switch:
  - id: !remove mute_toggle
  - platform: template
    id: mute_toggle
    internal: true
    optimistic: true

# 3. 灯带配置(esp-idf 必须使用 esp32_rmt_led_strip,且与板载灯平分 192 个 RMT 符号)
light:
  # 缩减远程包板载单颗灯的 RMT 符号占用(从 192 改为 96),腾出另一半 RMT 通道给外接灯带
  - id: !extend led_internal
    rmt_symbols: 96

  # 外接 SK6812 RGBW 灯带(接在 D2 / GPIO3)
  - platform: esp32_rmt_led_strip
    id: desk_light
    name: "管家台灯"
    pin:
      number: GPIO3                 # D2 引脚
      ignore_strapping_warning: true
    num_leds: ${led_count}
    chipset: SK6812                 # 若为普通三通道 WS2812B,可改为 WS2812 并改 channel_colors: GRB
    channel_colors: GRBW
    rmt_symbols: 96                 # 与 led_internal (96) 合计 192,契合 ESP32-S3 RMT 上限
    default_transition_length: 0.4s # 默认渐变过渡时长(与 effect 配合时自动生效)
    restore_mode: ALWAYS_OFF        # 上电默认关闭
    effects:
      - pulse:
          name: "缓慢呼吸"
          transition_length: 1.0s
          update_interval: 1.0s
          min_brightness: 25%
          max_brightness: 85%
      - pulse:
          name: "快速闪烁"
          transition_length: 0.25s
          update_interval: 0.3s
          min_brightness: 20%
          max_brightness: 100%

# 4. 顶盖电容触摸(D3 / GPIO4):平时直接开关台灯;若在对话中触摸则立即停止对话并恢复灯光
# 注:ESP32-S3 必须配置 0.25ms 测量周期,否则默认 8ms 会导致触摸失灵
esp32_touch:
  setup_mode: false                 # 首次调试阈值时可临时改为 true,在日志查看触摸前后的数值
  measurement_duration: 0.25ms
  sleep_duration: 0.5ms

binary_sensor:
  # 移除远程包默认绑在 GPIO3 上的物理按键,释放 GPIO3 给灯带,并将 user_button 映射到 GPIO4 电容触摸
  - id: !remove user_button
  - platform: esp32_touch
    id: user_button
    name: "触摸开关"
    pin: GPIO4                      # D3 引脚
    threshold: ${touch_threshold}
    on_press:
      - if:
          condition:
            # 若当前正在进行语音对话或正在播报回答,轻触可随时打断并恢复台灯原状
            lambda: return id(in_conversation) || id(va).is_running() || id(external_media_player).state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING;
          then:
            - lambda: id(has_tts_response) = false;
            - if:
                condition:
                  media_player.is_announcing:
                    id: external_media_player
                then:
                  - media_player.stop:
                      id: external_media_player
                      announcement: true
            - if:
                condition:
                  voice_assistant.is_running:
                then:
                  - voice_assistant.stop:
            - script.execute: restore_desk_light
          else:
            # 平时待机状态下,轻触直接开关台灯
            - if:
                condition:
                  light.is_on: desk_light
                then:
                  # 关灯前保存当前照明亮度与颜色(方便下次触摸开灯时恢复)
                  - lambda: |-
                      id(light_was_on) = false;
                      id(previous_brightness) = id(desk_light).remote_values.get_brightness();
                      id(previous_red)        = id(desk_light).remote_values.get_red();
                      id(previous_green)      = id(desk_light).remote_values.get_green();
                      id(previous_blue)       = id(desk_light).remote_values.get_blue();
                      id(previous_white)      = id(desk_light).remote_values.get_white();
                  - light.turn_off:
                      id: desk_light
                      transition_length: 0.5s
                else:
                  # 开灯时恢复台灯照明的暖白/上次颜色,避免恢复成语音助手的蓝青色
                  - lambda: id(light_was_on) = true;
                  - light.turn_on:
                      id: desk_light
                      brightness: !lambda return id(previous_brightness);
                      red: !lambda return id(previous_red);
                      green: !lambda return id(previous_green);
                      blue: !lambda return id(previous_blue);
                      white: !lambda return id(previous_white);
                      effect: "None"

# 5. 灯光状态切换与恢复脚本
script:
  # 切换到语音回答(缓慢呼吸)阶段
  - id: enter_replying_state
    mode: single
    then:
      - if:
          condition:
            lambda: return !id(has_tts_response);
          then:
            - lambda: |-
                id(has_tts_response) = true;
                id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
            - script.execute: control_leds
            - light.turn_on:
                id: desk_light
                brightness: 85%
                red: 0%
                green: 90%
                blue: 65%
                white: 0%
                effect: "缓慢呼吸"

  # 对话结束:将台灯恢复到唤醒前的状态(原本关着则慢慢熄灭,原本开着则恢复照明颜色与亮度)
  - id: restore_desk_light
    mode: restart
    then:
      - lambda: |-
          id(in_conversation) = false;
          id(has_tts_response) = false;
          id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
      - script.execute: control_leds
      - if:
          condition:
            lambda: return id(light_was_on);
          then:
            # 唤醒前台灯原本是开着的 → 先清除呼吸灯效,再以 1.2s 渐变恢复唤醒前的照明颜色与亮度
            - light.turn_on:
                id: desk_light
                effect: "None"
            - light.turn_on:
                id: desk_light
                transition_length: 1.2s
                brightness: !lambda return id(previous_brightness);
                red: !lambda return id(previous_red);
                green: !lambda return id(previous_green);
                blue: !lambda return id(previous_blue);
                white: !lambda return id(previous_white);
          else:
            # 唤醒前台灯原本是关着的 → 按 fade_out_time (1.5s) 慢慢平滑熄灭
            - light.turn_off:
                id: desk_light
                transition_length: ${fade_out_time}

# 6. 语音状态与灯光联动(字典组件自动与远程包合并,无需且不能写 id)
voice_assistant:
  # 阶段1:唤醒开始倾听 → 保存唤醒前台灯状态,并亮起唤醒常亮柔光
  on_listening:
    - lambda: |-
        if (!id(in_conversation)) {
          id(in_conversation) = true;
          id(light_was_on) = id(desk_light).remote_values.is_on();
          if (id(light_was_on)) {
            id(previous_brightness) = id(desk_light).remote_values.get_brightness();
            id(previous_red)        = id(desk_light).remote_values.get_red();
            id(previous_green)      = id(desk_light).remote_values.get_green();
            id(previous_blue)       = id(desk_light).remote_values.get_blue();
            id(previous_white)      = id(desk_light).remote_values.get_white();
          }
        }
        id(has_tts_response) = false;
        id(voice_assistant_phase) = ${voice_assist_waiting_for_command_phase_id};
    - script.execute: control_leds
    - light.turn_on:
        id: desk_light
        brightness: 75%
        red: 0%
        green: 70%
        blue: 100%
        white: 0%
        effect: "None"

  # 阶段2:用户说完话、进入 STT/LLM 思考阶段 → 快速闪烁
  on_stt_vad_end:
    - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id};
    - script.execute: control_leds
    - light.turn_on:
        id: desk_light
        brightness: 85%
        red: 30%
        green: 40%
        blue: 100%
        white: 0%
        effect: "快速闪烁"

  # 阶段3:思考完毕、开始播报语音回答(同时兼容流式 TTS 与普通 TTS)→ 稍慢的呼吸
  on_intent_progress:
    - if:
        condition:
          lambda: return !x.empty();
        then:
          - script.execute: enter_replying_state

  on_tts_start:
    - script.execute: enter_replying_state

  # 阶段4:对话结束 → 等待扬声器真正播完语音后,恢复到唤醒前的状态(或慢慢熄灭)
  on_end:
    - wait_until:
        not:
          voice_assistant.is_running:
    - if:
        condition:
          lambda: return id(has_tts_response);
        then:
          # 保持 replying 状态并等待 external_media_player 开始和完成语音播报,防止语音还没播完灯就灭了
          - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
          - script.execute: control_leds
          - wait_until:
              condition:
                media_player.is_announcing:
                  id: external_media_player
              timeout: 2.5s
          - wait_until:
              not:
                media_player.is_announcing:
                  id: external_media_player
    # 语音播报彻底完成后,恢复唤醒前的台灯状态(原本关则慢慢熄灭,原本开则恢复原亮度与颜色)
    - script.execute: restore_desk_light

  # 出错时:红灯提示 2 秒后恢复唤醒前状态
  on_error:
    - if:
        condition:
          and:
            - lambda: return !id(init_in_progress);
            - lambda: return code != "duplicate_wake_up_detected";
            - lambda: return code != "stt-no-text-recognized";
        then:
          - lambda: |-
              id(has_tts_response) = false;
              id(voice_assistant_phase) = ${voice_assist_error_phase_id};
          - script.execute: control_leds
          - light.turn_on:
              id: desk_light
              brightness: 90%
              red: 100%
              green: 0%
              blue: 0%
              white: 0%
              effect: "None"
          - delay: 2s
          - script.execute: restore_desk_light
处理 secrets(推荐)

上面我们为了方便直接将Wi-Fi SSID和明文密码输入在配置文件中,实际应用中建议通过配置 /config/esphome/secrets.yaml 载入。

在 ESPHome 界面左上角点击三个点,密钥,写入以下内容:

wifi_ssid: "<Wi-Fi SSID>"
wifi_password: "<明文Wi-Fi密码>"

然后把配置文件里的 Wi-Fi 部分改回:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

(这一步不是必须,但更安全,密码不会明文出现在配置里)

第五步:编译并烧录

点击安装,根据自己的方式编译烧录。

不用担心“变砖”,这就是为什么我说编译这个很麻烦,配置文件的格式要求非常严格,即使强制编译,大概率会出错,即使没有报错,它也自带三层防错机制:

  1. 语法与引脚拦截(写错无法编译):第一层防护

    如果你把引脚写错了(比如把不支持 Touch 的引脚配置成了触摸),或者 YAML 缩进打错了,点击 Install 时,ESPHome 根本不会开始编译。它会直接弹出红字报错,告诉你哪一行写错了。错误代码不会被写入主板。

  2. 安全模式 (Safe Mode) 自动自救:第二层防护

    假设代码语法全对,编译也通过了,但其中某个逻辑导致芯片不断死机重启(比如内存泄漏或死循环)。当 ESP32-S3 连续重启失败 10 次后,它会自动触发底层的 Safe Mode(安全模式)。此时它会关闭所有硬件功能,仅开启 Wi-Fi 和 OTA 升级通道。你依然可以在 Home Assistant 里看到它在线,并把修正后的代码通过无线(OTA)重新刷进去。

  3. 物理强刷兜底:第三层防护

    哪怕遇到最极端的情况(比如你不小心把 Wi-Fi 密码改错了导致它彻底断网,连安全模式都连不上路由器)。

    解法极简: 把外壳打开,用 Type-C 线重新插回电脑,在网页上再点一次烧录。物理线缆的烧录权限永远是最高的,任何软件错误都可以被一键覆盖清除。

唯一需要小心的错误: 软件配置怎么写都不会损坏硬件。但物理接线短路(比如把 5V 电源线不小心搭在了 GND 或者普通的信号引脚上)会瞬间烧毁主板。通电前务必检查 3 根线的焊点/接头是否独立清晰,没有互相连在一起。

第三阶段:接线及功能测试(裸机调试)

不管采用哪种方案,最终到了这一步,来到了整个项目最激动人心的时刻!为了确保一次性接线成功且不烧毁主板,在桌面上摆好主板、喇叭、灯带和导线,请严格按照以下引脚对应关系进行物理连接:

第一步:核心接线清单(对应 ReSpeaker Lite 主板丝印)

  1. 扬声器(声音输出)

    主板上自带了 5W 的功放和标准插座,一般不需要焊接,直接插。

    买一个匹配的 JST 2P 公头(带线的)端子最好。

  2. SK6812 灯带(视觉与灯效)

    灯带剪下需要的长度后,一共有 3 根线需要引出(注意必须接在灯带标有 DIN 或方向箭头起始的那一端):

    电源正极(5V / VCC) ➡️ 接主板边缘的 5V 焊盘。

    电源负极(GND) ➡️ 接主板边缘的 GND 焊盘。

    数据信号线(DIN / DI) ➡️ 接主板上方排针孔的 D2 焊盘。

  3. 电容触摸开关(物理交互)

    完全不需要买额外模块,只需一根普通的细导线(比如从废旧网线里抽出来的一根铜丝):

    导线一端 ➡️ 直接接在主板上方排针孔的 D3 焊盘 上。

    导线另一端 ➡️ 剥开一点皮,用胶带贴在一小片锡纸/铝箔纸/铜箔上。测试时,把这片锡纸用双面胶临时贴在一块废料亚克力或塑料板背面。

  4. 系统供电(能量来源)

    主板接口:板子底部的 Type-C 接口。

    外部设备:插上连接电脑或手机充电头的 Type-C 数据线。(测试阶段建议直接插电脑,方便同时看日志输出)。

第二步:通电前“三秒自检”清单(防烧毁)

在插上 Type-C 线通电之前,请务必肉眼或用手机放大镜检查以下三点(这是新手最容易翻车的地方):

查短路:检查灯带的 5V 和 GND 焊点、导线有没有连在一起,或者有没有不小心碰到旁边的金属元件。正负极短路通电会瞬间烧毁板子!

查方向:确认灯带的信号线接的是 DIN(数据输入),如果接成了 DOUT(数据输出),灯带通电后完全不会亮。

查隔离:确保那根接在 D3 的“触摸导线”悬空,千万不要让它碰到 5V 焊盘,否则会烧毁芯片的 GPIO 引脚。

第三步:通电测试流程

确认接线无误后,插上 Type-C 线,观察服务器的 journalctl -u ai-gateway -f 日志,如果是 接入Home Assistant ,则打开 ESPHome 日志面板看日志:是否成功连上 Wi-Fi。

摸锡纸:手指隔着废料塑料摸一下那片锡纸,看 HA 里“顶盖触摸开关”实体是否从 关闭 变成 开启。

试灯效:独立部署的在web页面后端进行测试,HomeAssistant方式的在仪表盘找到“AI Light”进行测试。

喊唤醒词/触摸唤醒:对它说一句唤醒词(或摸一下),看着灯光变呼吸状态,对它说“你好”,等待 DeepSeek 思考后,喇叭里传出清脆的回答。

第四阶段:物理结构与声学密封组装(装配落地)

由于每个人需求不同,我这次是做个台灯,下次可能做个乐高模型,再下次可能装在马里奥的玩具里,所以在外观这块确认好尺寸就可以制作安装了。

但是在组装环境,有些材料及注意事项简单说明一下:

  1. 喇叭密封音腔制作:

    用 B-7000 胶水 将喇叭单元、被动振膜固定在内腔体上,做气密性封边并静置固化。也就是喇叭后面最好达到密封效果,这样低音才会有质感,不会很空;另外,建议在腔体内放置吸音棉。

  2. 亚克力框架组装:

    使用专门的亚克力胶水,分子层的粘连,可以养鱼的那种。

  3. 触摸开关:

    锡纸/铜箔紧密贴在顶盖内侧。

  4. SK6812 灯带:

    选择合理固定灯带的方式,如果贴在亚克力板上,因为灯带温度较高,亚克力板可能会弯曲或者影响到整体外观。

  5. ReSpeaker Lite 主板位置:

    麦克风位置在板子两端,为了更好拾音,避免听不清楚,最好是水平固定在顶层或平放在内部。

第五阶段:日常使用调优

优化一:关闭不相关实体

默认对话情况下,HomeAssistant与DeepSeek交互时,每次都会附带公开的设备信息,将该设备的元数据转化为函数描述,以便大模型判断它是什么、能执行哪些操作。

例如一个开关包含开关、温度、定时等一系列的实体,但是实际上可能只需要控制开和关,因此无需额外暴露温度、定时等实体。在底层的工具定义中长这样:

{
  "name": "HassTurnOn",
  "description": "打开某个设备",
  "parameters": {
    "properties": {
      "name": {
        "type": "string",
        "description": "设备名称,例如:客厅吸顶灯 (light.living_room_light)"
      }
    }
  }
}

//默认附带其他不必要的设备信息
  • 单个设备损耗:每个设备根据支持的属性(开关、亮度、颜色、色温等),大约会占用 20 到 60 个 Token。

  • 累积放大效应:如果你家里有 30 个设备公开在列表中,每一次对话光是这批设备清单就会固定吃掉 600 ~ 1500 个输入 Token。

  • 高频损耗:每次你跟它说一句话,哪怕只是问一句“今天冷吗”,这几千个 Token 都会默默跟随着请求往返一次。

为了避免Token浪费,建议在 【语音助手】 中,将 【公开新实体】 关闭,然后手动选择需要公开的实体。

ESPHome远程服务器编译

如果HomeAssistant设备性能有限,可能会出现内存不足无法编译的情况,需要借用其他设备帮助编译。

在其他设备上创建 docker compose 进行配置:

services:
  esphome:
    container_name: esphome
    image: ghcr.io/esphome/esphome
    volumes:
      - ./config:/config
      - /etc/localtime:/etc/localtime:ro
    restart: always
    privileged: true
    network_mode: host
    environment:
      - ESPHOME_USERNAME=<username>
      - ESPHOME_PASSWORD=<password>

然后访问:http://<服务器ip>:6052 登陆。

回到HomeAssistant的ESPHome,点击右上角三个点 - 设置,然后在配对请求与构建服务器这几个栏目里面,与上述部署在服务器的esphome进行配对即可。这样每次编译固件,点击通过网络构建后,都会发往服务器端进行构建。


独立方案

在这过程中,我有个作死的ideal: 不想通过home assistant,因为我不需要控制家里设备,家里设备都是Apple Home负责的,如果在ubuntu server上直接部署服务,也就是:

[ReSpeaker Lite (ESP32-S3)] 
        │ (Wi-Fi 双向低延迟音频流 / WebSocket / UDP)
        ▼
[Ubuntu 服务器: 独立中控服务 (Python)]
        ├─> 听觉: SenseVoice (本地 10300 端口,流式 STT/VAD)
        ├─> 大脑: DeepSeek API (推理/对话,保留记忆逻辑)
        └─> 喉咙: Kokoro / Azure TTS (毫秒级合成,流式推回音频)
但是!!!!

目前市面上 没有任何一款现成的 .bin 固件 可以直接刷进 ESP32-S3 直接适配。我们这一套实际上是高度定制的“简化版裸 PCM + 私有 SK6812 状态机”协议。

它与目前主流的两类现成固件在底层协议和硬件驱动上均存在冲突。

因此,需要自行写代码进行编译。

以下都是失败经历,但是又不想死心,一起发出来吧,后面我继续研究研究。

独立方案的线路就是ReSpeaker Lite直接通往Ubuntu Server,然后Ubuntu Server处理后直接返回ReSpeaker Lite。

也就是需要:

  • 在 Ubuntu 上跑一个独立的中控服务:

    • 可以使用轻量的 Python 脚本,对外暴露一个 WebSocket 端口(比如 28888),专门给开发板连接。
  • 给 ReSpeaker Lite 刷入独立的 Wi-Fi 客户端固件:

    • 开发板不再烧录 ESPHome 固件,而是刷入支持独立 WebSocket 音频流的固件。

第一步:目标

之前部署的服务仍然需要继续使用,所以只是到homeassistant那边有出入。

把全部功能(处理逻辑)都放到Ubuntu Server上,需要编写一个后端程序,具有以下流程:

  1. 👂 接收与监听 (WebSocket 入口)

    • 逻辑:监听 28888 端口,接收开发板通过 WebSocket 发来的信号(开始录音、停止录音)以及二进制的音频流(说话的原始声音)。

    • 处理:会把这些音频碎片拼起来,过滤掉极短的杂音(防误触)。

  2. ✍️ 语音转文字 (STT - SenseVoice)

    • 逻辑:音频一接收完,立马通过 Wyoming 协议把音频发给本地的 SenseVoice 容器。

    • 作用:将说的语音精准翻译成文字,并剔除掉乱七八糟的内部情绪标签。

  3. 🧠 思考与记忆 (LLM + RAG 向量检索)

    • 短期记忆:维护列表(messages_history),能记住最近聊的十几句话(实现多轮连续对话)。

    • 长期记忆:搭载 “函数调用(Tool Calling)” 能力。如果 DeepSeek 发现在说自己的喜好,它会自动调用代码里的 Ollama + ChromaDB 向量数据库把信息刻在硬盘上;如果需要回忆,它也会自动去库里把之前存的记忆翻出来。

    • 思考:带着上下文和记忆,向 DeepSeek 发起请求,获取极度拟人化的文本回复。

  4. 🗣️ 文字转语音与下发 (TTS - Azure/Kokoro)

    • 逻辑:拿到大模型的文本回复后,可选语音模型,自定义配置的引擎(云端的 Azure 或本地的 Kokoro)。

    • 下发:拿到音频后,不是一次性全砸给开发板(会撑爆内存),而是切成 4KB 的小块,配合“开始播放/停止播放”的信令,平滑地发回给开发板播放。

因此,我额外增加了一个前端Web 可视化配置面板,用于后期调试灯光、唤醒词、音量以及更改对话、语音模型等。

  1. 零重启热更新(Hot-Reload):在Web界面上点一下“保存”,下一句跟开发板说话时,新的声音、新的模型、新的人设立即生效,无需重启服务,开发板也不会断开连接。

  2. 代码与密钥彻底分离:代码里不包含任何明文 API Key,所有敏感信息和可变参数全部存在本地自动生成的配置文件 config.json 中,代码升级或分享时再也不怕泄露密钥。

语音唤醒预计采用 Sherpa-ONNX 方案,免训练开箱即用! 模型本身包含全部汉字声母/韵母音素,只需写一行带声调拼音,类似: h óu g ē @猴哥 即可声学层流式匹配。同时也兼顾了以后使用英文的选择。

第二步:编写代码

以Ubuntu Server为例:

安装python:

apt install python3-venv python3-full -y

创建工作目录:

mkdir -p /var/www/AI-Light
cd /var/www/AI-Light

创建程序文件:

vim gateway.py

内容为:

# 留空

# 之前写过一些代码,但是由于固件那边一直有问题,等完成后再更新

创建虚拟环境并安装依赖:

python3 -m venv ailight
source ailight/bin/activate

# 安装依赖:OpenAI(用来调DeepSeek)、ChromaDB(存记忆)、Azure语音SDK、HTTP客户端
pip install openai chromadb azure-cognitiveservices-speech requests websockets wyoming aiohttp sherpa-onnx numpy pypinyin

# 下载Sherpa-ONNX官方中英双语声学唤醒模型并解压到当前目录
curl -L -O https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20.tar.bz2
tar xf sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20.tar.bz2
rm sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20.tar.bz2

运行:

python3 gateway.py

Ctrl + C 退出运行,并且可以输入 deactivate 退出虚拟环境。

灯带的设计思路: “双层状态机(基底照明层 Base Layer + 交互叠加层 Overlay State)” 架构

当前所处阶段 当【平时关闭 (power: false)】时的灯效表现 当【夜间打开常亮 (power: true)】时的柔和叠加表现 对话结束后的去向
1. 待机状态 (idle) 全灭(0 功耗、0 光污染) 保持你设定的常亮颜色与亮度(如暖白光 80%) —
2. 唤醒倾听 (listening) 亮起柔和的冰蓝色微光(提示正在听你说话) 不改变全屋照明主色调,仅在当前常亮色上做 +15% 的轻微提亮涟漪 进入思考
3. 大脑思考 (thinking) 幽蓝色慢速呼吸灯(明暗交替) 在当前常亮照明亮度的 80% ~ 100% 之间做极轻微的呼吸起伏(既有反馈又不晃眼) 进入回答
4. 语音回答 (speaking) 青绿色声波律动 保持常亮色,仅伴随说话做 ±10% 的柔和微光波动 立即恢复为全灭或常亮
5. 异常/未听清 (error) 琥珀红闪烁 2 次 在常亮背景上轻微暖橙色闪动 1 秒 立即恢复为全灭或常亮

日常与夜间使用

  1. 白天 / 平时待机(默认 power: false):

    灯带完全熄灭。当喊出唤醒词,灯带立刻泛起柔和的冰蓝色(wake_glow) -> 说完话等待 DeepSeek 思考时,转为深蓝色慢呼吸(breathe) -> AI开口回答时,转为青绿色律动(speak_pulse) -> 话音落下,灯带自动熄灭恢复安静。

  2. 开灯常亮( power: true):

    power 变为 true,此时唤醒AI提问:灯带不会熄灭也不会突兀变色,而是在当前的灯光亮度基础上做 82% ~ 100% 的微光呼吸起伏(subtle_breathe),对话一结束立刻恢复之前常亮状态。

  3. 网页一键预览:

    gateway.py 附带的后端配置界面: http://服务器IP:28889,在 SK6812 智能灯带控制 栏目,设置 5 个实时预览按钮(🎤唤醒亮灯、🧠思考呼吸、🗣️回答律动、⚠️错误闪烁、🔄恢复待机),方便直接在手机上测试各种灯光效果。

第三步:模拟测试

简单再写个python脚本,对接上面的几个接口,模拟人声给它发送一段语音,测试是否能够正常接收并生成语音:

可以单起一个测试目录,或者就在这个目录操作。

新建一个SSH窗口,并进入同一个目录 (因为我的测试代码里写了读取同一目录的配置文件 config.json ,其他目录的话更改11行的路径即可,所以需要上一步访问Web界面 http://服务器ip:28889 先生产配置文件) :

进入工作目录:

cd /var/www/AI-Light

创建程序文件:

vim test.py

内容为:

import asyncio
import websockets
import json
import os
import wave
import azure.cognitiveservices.speech as speechsdk

# ================= 1. 动态加载网关配置 =================
def load_azure_config():
    """从网关的 config.json 中自动读取 Azure 密钥"""
    config_path = "./config.json"
    if not os.path.exists(config_path):
        print("❌ 找不到 config.json,请先运行 gateway.py 并在 Web 后台保存一次配置。")
        exit(1)

    with open(config_path, "r", encoding="utf-8") as f:
        config = json.load(f)

    api_key = config.get("azure_speech_key")
    region = config.get("azure_region", "eastasia")

    if not api_key:
        print("❌ config.json 中未配置 Azure 密钥,生成假录音失败。请先去 Web 控制台填写!")
        exit(1)

    return api_key, region

# ================= 2. 生成模拟音频 =================
def text_to_pcm(text, api_key, region):
    print(f"[模拟器] 正在调用 Azure 将文本转为 16KHz 麦克风录音流...")
    speech_config = speechsdk.SpeechConfig(subscription=api_key, region=region)
    # 小智固件发送的麦克风音频是 16KHz 16bit 单声道 PCM
    speech_config.set_speech_synthesis_output_format(speechsdk.SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm)
    pull_stream = speechsdk.audio.PullAudioOutputStream()
    stream_config = speechsdk.audio.AudioOutputConfig(stream=pull_stream)
    synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=stream_config)
    result = synthesizer.speak_text_async(text).get()
    return result.audio_data

# ================= 3. 主测试逻辑 =================
async def mock_board():
    api_key, region = load_azure_config()
    uri = "ws://127.0.0.1:28888"
    print("🤖 模拟开发板已启动!")

    # 交互式输入测试文本
    print("-" * 40)
    user_input = input("🎤 请输入你要对管家说的话 (按回车发送): ")
    if not user_input.strip():
        print("未输入文本,测试结束。")
        return
    print("-" * 40)

    # 转换为假录音
    pcm_data = text_to_pcm(user_input, api_key, region)
    if not pcm_data:
        print("音频生成失败。")
        return

    print(f"[模拟器] 成功生成 {len(pcm_data)} 字节的音频数据,准备发送...")

    try:
        async with websockets.connect(uri) as ws:
            print("[模拟器] 🔗 已通过 WebSocket 连接到 AI 网关!")

            # 严格按照小智协议发送数据流
            await ws.send(json.dumps({"type": "listen", "state": "start"}))
            await ws.send(pcm_data)
            await ws.send(json.dumps({"type": "listen", "state": "stop"}))
            print("[模拟器] 📤 音频已发送,等待管家回复...\n")

            # 接收服务端的指令和音频回复,并直接存为 WAV
            with wave.open("reply.wav", "wb") as wav_file:
                wav_file.setnchannels(1)      # 单声道
                wav_file.setsampwidth(2)      # 16bit = 2字节
                wav_file.setframerate(24000)  # 网关下发的是 24000Hz (兼容 Kokoro 和优化后的 Azure)

                async for message in ws:
                    if isinstance(message, str):
                        print(f"📡 收到网关信令: {message}")
                        if "stop" in message:
                            break
                    elif isinstance(message, bytes):
                        wav_file.writeframes(message)
                        print(f"🎵 收到音频分片: 写入 {len(message)} 字节")

            print("\n✅ 全链路测试完成!管家的语音回复已生成: reply.wav (可直接双击播放)")

    except ConnectionRefusedError:
        print("❌ 连接失败!请确认 gateway.py 正在运行,并监听了 28888 端口。")

if __name__ == "__main__":
    asyncio.run(mock_board())

创建虚拟环境并安装依赖:

python3 -m venv test
source test/bin/activate

pip install websockets azure-cognitiveservices-speech

运行:

python3 test.py
注意: 此时,是同时打开2个SSH窗口,一个是运行 gateway.py 服务端,另一个就是 test.py 测试端。

根据两个界面中显示的信息判断是否测试成功。

之前我测试过,记忆库里有我喜欢喝冰拿铁,所以它检索到记忆库中知道我喜欢喝冰拿铁,就顺手回答里。

😂:不过现在10月天气已经转凉了,再喝冰拿铁感觉有点牙疼~

之后可以 Ctrl + C 退出运行,然后输入 deactivate 退出虚拟环境。

第四步:烧录开发板

ReSpeaker Lite 板载了两颗芯片:一颗是负责音频降噪的 XMOS (XU316),另一颗是负责联网和主控的 XIAO ESP32S3。我们要对两个芯片分别烧录。

先刷 XMOS 的 I2S 固件

出厂时,XMOS 芯片可能烧录的是 USB 声卡固件或者出厂测试固件。

如果不通过 dfu-util 把 xmos_firmwares 目录下的专用 I2S 固件刷进 XMOS 芯片,它就不会通过标准 I2S 总线把处理好的干净音频吐给 ESP32-S3。也就是说,即使你在 ESP32-S3 上把固件烧得再完美,如果 XMOS 没有刷成 I2S 模式,ESP32-S3 也听不到任何声音(或者喇叭放不出声)。

下载地址: https://github.com/respeaker/reSpeaker_Lite/tree/master/xmos_firmwares

下载:respeaker_lite_i2s_dfu_firmware_48k_v1.1.0_ch0-asr_ch1-mww.bin

同目录下有 xmos_firmwares/dfu_guide.md 刷机教程,可以进行查看。

先把 Type-C 数据线插在音频底板上的 XMOS 接口(注意:不是插在 ESP32 小板上)。

以macOS为例:

# 1.安装homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 2.安装dfu-util
brew install dfu-util

# 3.确认Respeaker Lite有没有被检测到
dfu-util -l

# 4.刷固件
dfu-util -R -e -a 1 -D /path/to/dfu_firmware.bin

# 5.设备重启

编译与烧录ESP32S3

这边我重点研究了Respeaker Lite开发版的说明,根据官方文档简单梳理了一下编译的步骤:

  1. 下载Arduino IDE: Arduino IDE Software | Arduino

  2. 配置 ESP32 开发板支持包

    • 打开 Arduino IDE,按照 Seeed Studio 官方配置指南 进入 文件 → 首选项,在“其他开发板管理器地址”中填入:https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json

    • 在左侧 开发板管理器 中搜索 esp32(Espressif Systems),点击安装。

  3. 安装 3 个依赖库

    • 点击左侧 库管理器 (Library Manager),依次搜索并安装:

      • WebSockets(作者:Markus Sattler)

      • ArduinoJson(作者:Benoit Blanchon)

      • Adafruit NeoPixel(作者:Adafruit)

  4. 修改顶部 4 项配置参数

    • 粘贴下方的代码中找到 用户核心参数配置区,填入你的实际信息:

      • WIFI_SSID / WIFI_PASSWORD:2.4GHz Wi-Fi 名称与密码。

      • SERVER_HOST:服务器局域网 IP(即运行 gateway.py 的服务器 IP)。

      • LED_COUNT:从 SK2812 灯带上实际剪下的灯珠颗数。

  5. 选择开发板并开启 PSRAM 烧录

    • 将 Type-C 数据线插在 XIAO ESP32S3 小板 的接口上(注意这次是插 ESP32 口,不是底板 XMOS 口)。

    • 顶部菜单选择 工具 (Tools):

      • Board:选择 XIAO_ESP32S3

      • PSRAM:务必选择 OPI PSRAM(开启 8MB 扩展内存用于语音流缓冲)

      • Eraser All Flash before sketch Upload:建议选择 Enabled

    • 点击左上角 上传 (Upload)。

  6. 完成烧录: 顺便插拔一下接头。

固件代码:

# 留空
即使方案确定了,之前也写过一些固件代码进行编译测试,但是实际体验效果其实并不是很好。

添加HomeKit桥接

由于采用独立方案,并不经过HomeAssistant,因此无法通过HomeKit Bridge的方式添加到HomeKit中进行台灯开关的操控,因此需要单独搭建一个 HAP-python。

由于 HAP-python(Apple HomeKit 协议的 Python 实现)内部维护了独立的加密套接字循环,最稳妥、互不干扰的做法是:写一个独立的 homekit_bridge.py 伴生脚本(仅 80 行代码),它会把 AI-Light 伪装成一台苹果官方认证的 全彩智能灯(支持开关 + 亮度 + 调色盘)。

第一步:在 Ubuntu 上安装系统依赖与 HAP-python

HomeKit 协议底层依赖 avahi(也就是苹果的 Bonjour / mDNS 局域网广播协议)以及加密库。依次在终端执行以下两条命令:

# 1. 安装 Ubuntu 系统的 mDNS 广播支持库
apt-get update && apt-get install -y libavahi-compat-libdnssd-dev avahi-daemon

# 2. 在虚拟环境中安装 HAP-python 和色域转换支持
/var/www/AI-Light/ailight/bin/pip install HAP-python[QRCode] requests

_(如果服务器开了 ufw 防火墙,放行 HomeKit 通信端口与 mDNS 端口:ufw allow 5353/udp 和 MARKDOWN_HASHca4daa43368010ff8c4d96e55b8a476aMARKDOWNHASH)

第二步:创建 homekit_bridge.py Apple Home桥接脚本

在 /var/www/AI-Light/ 目录下新建 homekit_bridge.py:

vim /var/www/AI-Light/homekit_bridge.py

粘贴以下完整代码(它会自动將 Apple Home 的 HSB 色相/饱和度/亮度 转换为网关所需的 RGB Hex 十六进制颜色,并实时调用 http://127.0.0.1:28889/api/light ):

import colorsys
import logging
import requests
from pyhap.accessory import Accessory
from pyhap.accessory_driver import AccessoryDriver
from pyhap.const import CATEGORY_LIGHTBULB

logging.basicConfig(level=logging.INFO, format="[HomeKit] %(message)s")

GATEWAY_API = "http://127.0.0.1:28889"

class AILightBulb(Accessory):
    """将 AI-Light 映射为 Apple Home 全彩调光灯"""
    category = CATEGORY_LIGHTBULB

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # 加载苹果标准的 Lightbulb 服务(包含 On, Brightness, Hue, Saturation 四个特征)
        serv_light = self.add_preload_service(
            "Lightbulb", chars=["On", "Brightness", "Hue", "Saturation"]
        )

        self.char_on = serv_light.configure_char(
            "On", value=True, setter_callback=self.set_on
        )
        self.char_brightness = serv_light.configure_char(
            "Brightness", value=80, setter_callback=self.set_brightness
        )
        self.char_hue = serv_light.configure_char(
            "Hue", value=30, setter_callback=self.set_hue
        )
        self.char_saturation = serv_light.configure_char(
            "Saturation", value=80, setter_callback=self.set_saturation
        )

        # 本地缓存当前状态
        self.power = True
        self.brightness = 80
        self.hue = 30.0
        self.saturation = 80.0

    def _sync_to_gateway(self):
        """将 Apple Home 的 HSB 颜色转换为 #RRGGBB 并推送给 gateway.py"""
        r, g, b = colorsys.hsv_to_rgb(
            self.hue / 360.0, 
            self.saturation / 100.0, 
            1.0
        )
        hex_color = "#{:02x}{:02x}{:02x}".format(
            int(r * 255), int(g * 255), int(b * 255)
        )
        payload = {
            "power": bool(self.power),
            "color": hex_color,
            "brightness": int(self.brightness)
        }
        try:
            requests.post(f"{GATEWAY_API}/api/light", json=payload, timeout=2)
            logging.info(f"Siri/家庭控制生效 -> 开关:{self.power} | 颜色:{hex_color} | 亮度:{self.brightness}%")
        except Exception as e:
            logging.error(f"无法连接到 gateway.py (28889): {e}")

    def set_on(self, value):
        self.power = bool(value)
        self._sync_to_gateway()

    def set_brightness(self, value):
        self.brightness = int(value)
        self._sync_to_gateway()

    def set_hue(self, value):
        self.hue = float(value)
        self._sync_to_gateway()

    def set_saturation(self, value):
        self.saturation = float(value)
        self._sync_to_gateway()

    @Accessory.run_at_interval(5)
    def run(self):
        """每 5 秒从 gateway.py 同步一次状态,确保当你用小智语音或网页改色时,苹果家庭 App 里也跟着变色"""
        try:
            resp = requests.get(f"{GATEWAY_API}/api/config", timeout=2)
            if resp.status_code == 200:
                light = resp.json().get("light_state", {})
                power = bool(light.get("power", True))
                bright = int(light.get("brightness", 80))
                hex_c = light.get("color", "#ff9933").lstrip("#")

                if len(hex_c) == 6:
                    r, g, b = [int(hex_c[i:i+2], 16) / 255.0 for i in (0, 2, 4)]
                    h, s, _ = colorsys.rgb_to_hsv(r, g, b)
                    hue_deg = round(h * 360.0, 1)
                    sat_pct = round(s * 100.0, 1)

                    if self.power != power:
                        self.power = power
                        self.char_on.set_value(power)
                    if self.brightness != bright:
                        self.brightness = bright
                        self.char_brightness.set_value(bright)
                    if abs(self.hue - hue_deg) > 2:
                        self.hue = hue_deg
                        self.char_hue.set_value(hue_deg)
                    if abs(self.saturation - sat_pct) > 2:
                        self.saturation = sat_pct
                        self.char_saturation.set_value(sat_pct)
        except Exception:
            pass

if __name__ == "__main__":
    # 监听 51826 端口,自动将配对状态保存在 accessory.state 文件中
    driver = AccessoryDriver(port=51826, persist_file="./accessory.state")
    accessory = AILightBulb(driver, "AI 语音氛围灯")
    driver.add_accessory(accessory=accessory)
    driver.start()

第三步:运行并在 iPhone “家庭” App 中扫码/输入配对码添加

  1. 首次运行查看配对码 在终端里直接运行它:

    /var/www/AI-Light/ailight/bin/python3 /var/www/AI-Light/homekit_bridge.py

    终端屏幕上会显示一个 二维码 和一串 8 位数的 HomeKit 设置代码。

  2. 在 iPhone 上添加配件

    • 打开 iPhone 自带的 “家庭 (Home)” App -> 点击右上角 + 号 -> “添加配件”。

    • 直接用摄像头扫终端里的二维码;或者点击下方的 “更多选项...”,你会看到屏幕上已经搜出了一个叫 “AI 语音氛围灯” 的灯具图标!

    • 点击它,如果提示“未认证配件”,点击“仍然添加”,输入终端里显示的 8 位数字配对码,即可完成绑定。

第四步:加入 Systemd 开机自启(配对成功后)

配对成功后,按 Ctrl+C 停止脚本,写一个守护进程让它和 ai-gateway 一起在后台永久运行:

vim /etc/systemd/system/ai-homekit.service

粘贴以下内容并保存:

[Unit]
Description=Apple HomeKit Bridge for AI-Light
After=network.target ai-gateway.service

[Service]
Type=simple
User=root
WorkingDirectory=/var/www/AI-Light
ExecStart=/var/www/AI-Light/ailight/bin/python3 /var/www/AI-Light/homekit_bridge.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

启动并设为开机自启:

systemctl daemon-reload
systemctl enable ai-homekit
systemctl start ai-homekit

Bad Ending

前两天完成的,接入ESPHome的体验还不错。灯光效果也很好,电容触摸开关一开始配置的是用于开启对话开关,后来改为灯光开关比较实用。

硬要说的话,其实任务已经圆满完成了,没有必要为了强行采用独立方案而纠结。

而且独立方案减少的那么一点点延迟远远没有实际意义,由于Azure TTS的服务器在国外,整个链路的延迟大部分都在这里。

准备接着搞独立方案了~

0 0 投票数
文章评分
订阅评论
提醒
guest

0 评论
最旧
最新 最多投票
滚动至顶部
0
希望看到您的想法,请您发表评论x