📋 專案概述
上週五晚上做舒芙蕾,滿手蛋黃糊進行到「筛糖 40 克」這一步,食譜下一步是什麼?把手在圍裙上狂擦、解鎖手機、開筆記型 App——螢幕上四個指印,糖還撒了一檯面。廚房是最需要語音助理的房間,也是最不适合放智慧音箱的房間:
- 斷網變磚:颱風天斷網日,NT$1,500 的音箱連「現在幾點」都不肯報——它的腦在三千公里外的機房。
- 訂閱制:進階語音技能每月 99 元,一年繳完能買兩台本專案的主機。
- 隱私:你家的談話、小孩的童言童語、吵架的內容——全部躺在別人公司的伺服器上,隱私政策寫得再漂亮,資料就是不在你家。
本專案把架構反過來:喚醒詞離線跑在 ESP32-S3 本機(esp-sr WakeNet,斷網照樣聽得懂「小天小天」),語音辨識與大模型走 NAS 上的本地服務(faster-whisper + Ollama + edge-tts),音訊一個位元都不出家門:
- 喚醒 → 錄音 → NAS 辨識 → Qwen2.5-7B 回答 → 語音回放,全程內網,實測一句話 round-trip 約 2.4 秒
- 本地指令直通:「幾點了」「NAS 狀態」「計時 12 分鐘」由韌體直接執行,不繞 LLM,<300ms
- 實體靜音開關物理斷開麥克風供電——比任何隱私政策都誠實;韌體同時監測 MIC 電源軌,開關壞了燈也會告訴你
- 離線降級:NAS 掛了仍能報時、計時、口頭設提醒;燈環四色狀態(待機/聆聽/思考/播放)
- 播放中凍結採集——它不會問自己問題然後自己回答,然後自己回答到早上三點
為什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| 喚醒算力 | 雙核 240MHz + SIMD 指令集,本機即時跑 WakeNet 的入門門檻(C3/C6/C2 單核跑不動) |
| 錄音緩衝 | N16R8 的 8MB PSRAM:8 秒 16kHz 16bit 環形緩衝只用掉 1/4 |
| 雙 I2S | 麥克風與喇叭各自獨立外設與時鐘,互不打架 |
| 閃存 | 16MB:WakeNet 模型 + 提示音樣板 + OTA 雙分區全塞得下 |
🖥️ NAS 端需求(已有 NAS/小主機則零成本):Docker 三件套——faster-whisper(int8/base 模型,2GB RAM)、Ollama + qwen2.5:7b(8GB RAM 起)、edge-tts。全機跑在自家 NAS 上,一台 24 小時開著的機器就夠。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3 DevKitC N16R8 | 1 | ~210 元 | 務必 OPI PSRAM 版,QSPI 板跑不了大緩衝 |
| 麥克風 | INMP441 I2S | 1 | ~35 元 | 線長 ≤10cm,遠離喇叭 |
| 功放 | MAX98357A 3W I2S | 1 | ~55 元 | B 類發熱屬正常 |
| 喇叭 | 4Ω 3W 52mm 全頻 | 1 | ~45 元 | 密閉腔體見列印設計 |
| 指示 | WS2812B 12 燈環 | 1 | ~25 元 | 狀態呼吸燈 |
| 按鍵 | 6×6 微動 ×2 | 2 | ~10 元 | 對話鍵/模式鍵 |
| 靜音開關 | 船型開關(串 MIC VDD) | 1 | ~10 元 | 物理斷採集,本專案靈魂 |
| 電源 | 5V 2.5A USB-C | 1 | ~90 元 | 喇叭峰值要夠,別用電腦 USB 孔湊合 |
| 列印材料 | PLA ~70g | — | ~20 元 | 音箱腔體 |
| 合計 | ~690 元 | NAS 端既有算力不計 |
🔌 接線圖
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4 / 5 / 6 | INMP441 SCK / WS / SD | I2S #1 專供麥克風;L/R 腳接地(左聲道) |
| GPIO12 | MIC VDD 電源軌偵測 | 经 100k/100k 分壓讀 3V3 軌——靜音開關斷電時讀到 0,燈號同步熄滅 |
| GPIO13 | 對話按鍵 → GND | 內部上拉;按下等同喊喚醒詞 |
| GPIO7 / 15 / 16 | MAX98357A BCLK / LRC / DIN | I2S #2 專供喇叭,與 MIC 完全隔離 |
| GPIO17 | MAX98357A SD_MODE | 拉高才出聲;休眠拉低消背景嘶聲 |
| GPIO10 | WS2812 環 DIN | 串 330Ω;5V 供電就近 1000µF |
| 3V3 / 5V | MIC+燈環 3V3;功放直接 5V | 喇叭峰值電流別過板載 LDO,會保護重啟 |
ESP32-S3 N16R8 INMP441 (MIC, I2S#1)
GPIO4 ─────────────────── SCK
GPIO5 ─────────────────── WS
GPIO6 ─────────────────── SD
3V3 ──[船型靜音開關]────── VDD ← 物理斷麥
GPIO12 ←─┬──[100k]── VDD軌 ← 開關狀態偵測
└──[100k]── GND
GND(L/R 接地=左聲道)
ESP32-S3 N16R8 MAX98357A (AMP, I2S#2)
GPIO7 ─────────────────── BCLK
GPIO15 ────────────────── LRC
GPIO16 ────────────────── DIN
GPIO17 ────────────────── SD_MODE(拉高出聲)
5V ────────────────────── VIN(勿經板 LDO)
OUT+ / OUT− ───────────── 4Ω 3W 喇叭
⚠️ INMP441 與喇叭距離 ≥8cm,中間最好有隔板——不然 MAX98357A 一發聲就回錄自己的聲音,燈環永遠停在「聆聽」。GAIN 腳懸空=9dB 對廚房綽綽有餘,嫌大聲接 100k 到 GND。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 音箱腔體(下座,密閉 260cc) | 1 | PLA | 深綠 | ~3.5 hr |
| 上蓋(電子艙 + MIC 減震座) | 1 | PLA | 深綠 | ~2 hr |
| 燈環導光圈 | 1 | PLA | 乳白 | ~25 min |
| 壁掛支架(可選) | 1 | PLA | 深綠 | ~30 min |
列印設定
- 材質:PLA 即可(室内、無熱源;別跟杯墊篇學——那是貼著鋁板才禁 PLA)
- 填滿:腔體 28% 三角(剛性抗谐振)、上蓋 15%
- 層厚:0.2mm;腔體牆厚 2.4mm(3 層)減少箱震
- 後處理:腔體內壁噴 acrylic 封孔漆兩遍——漏音封住,低頻差一檔變一檔
設計要點
- 密閉腔 + 倒相孔:52mm 全頻需要一个 260cc 的腔;倒相孔 φ22×40mm 調諧 65Hz,人聲底厚不空心
- MIC 減震座:INMP441 焊在小板上,小板四角卡發泡墊——不減震的話喇叭低音會「搖麥克風」錄進轟轟聲
- MIC 聲孔朝前上 45°:對準站立視線方向,廚房操作檯距離 60~90cm 收音最好
- 靜音開關側置 + 狀態窗:開關撥到「靜音」時露出紅色標記,一眼可辨,不用猜
💻 NAS 端服務(Docker Compose)
# docker-compose.yml — 三件套全內網,重啟自動拉起
services:
asr:
image: ghcr.io/aquariusmay/faster-whisper:latest # 或自建 python 鏡像
command: python server.py --model base --compute-type int8 --port 8091
volumes: [ "./whisper-cache:/root/.cache" ]
deploy: { resources: { limits: { memory: 2G } } }
llm:
image: ollama/ollama:latest
command: serve
volumes: [ "./ollama:/root/.ollama" ]
environment: [ "OLLAMA_HOST=0.0.0.0:11434" ]
deploy: { resources: { limits: { memory: 8G } } }
gateway:
image: python:3.12-slim
volumes: [ "./gateway:/app" ]
working_dir: /app
command: uvicorn app:app --host 0.0.0.0 --port 8090
environment:
- ASR_URL=http://asr:8091/asr
- LLM_URL=http://llm:11434/api/generate
- TTS_VOICE=zh-TW-HsiaoChihNeural # edge-tts 台灣腔
ports: [ "8090:8090" ] # ESP32 只吃 gateway 一個入口
Gateway(app.py,約 60 行 FastAPI)只做三件事:POST /asr 收 WAV 轉文字;把文字丟 Ollama 生成回答(系統提示詞限制 60 字以內、口語化);POST /tts 把回答轉 16kHz 單聲道 WAV 回傳。S3 端不需要知道裡面跑的是哪家模型——換模型 = 改一行 compose,設備端零改動。
💻 程式碼(韌體)
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-S3 本地端 AI 語音助理 | LAN-only Voice Assistant
* ------------------------------------------------------------
* 功能:WakeNet 離線喚醒、錄音上 NAS 辨識+LLM 回答+TTS 回放、
* 本地指令直通、實體靜音偵測、離線降級報時計時
* 硬體:ESP32-S3 N16R8 + INMP441 + MAX98357A + WS2812 燈環
* 供電:USB-C 5V 2.5A(喇叭峰值)
* 相依:Arduino core for ESP32 ≥3.0、esp-sr(內建)、
* ESP32-audioI2S 或本檔自錄 WAV 直傳自撥(本檔做法)
* 板型:ESP32S3 Dev Module / PSRAM: OPI / Flash: 16MB
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <ESP_I2S.h>
#include <ESP_SR.h>
#include <Adafruit_NeoPixel.h>
#include <math.h>
/* ------------------------------------------------------------
* 1. 使用者設定區
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* GATEWAY = "http://192.168.8.120:8090"; // NAS gateway
#define PIN_MIC_SCK 4
#define PIN_MIC_WS 5
#define PIN_MIC_SD 6
#define PIN_AMP_BCLK 7
#define PIN_AMP_LRC 15
#define PIN_AMP_DIN 16
#define PIN_AMP_SD 17
#define PIN_LED 10
#define PIN_MICSENSE 12
#define PIN_BUTTON 13
const uint32_t SAMPLE_HZ = 16000; // whisper 訓練採樣率,改 44100 辨識率雪崩
const uint16_t VAD_SILENCE = 700; // 尾端點 silence ms:說停 0.7s 即結東
const uint16_t MAX_REC_MS = 8000; // 單句上限,防單向演講變 30 秒上傳
const float VAD_LEVEL = 0.012f; // RMS 能量閘門(環境噪音大就調高)
/* ------------------------------------------------------------
* 2. 狀態機
* ------------------------------------------------------------ */
enum App { IDLE, LISTEN, UPLOAD, THINK, PLAY, MUTED };
App app = IDLE;
uint32_t appMs = 0;
int16_t* recBuf = nullptr; // PSRAM 環形緩衝
size_t recMax = 0, recLen = 0;
I2SClass i2sMic, i2sAmp;
Adafruit_NeoPixel ring(12, PIN_LED, NEO_GRB + NEO_KHZ800);
/* ------------------------------------------------------------
* setLed —— 狀態燈(顏色即狀態,拆機維修時一眼定位)
* IDLE 呼吸綠 / LISTEN 青 / THINK 橙旋轉 / PLAY 藍 / MUTED 全滅
* ------------------------------------------------------------ */
void setLed(uint32_t c, bool spin = false) {
static int ph = 0; ph++;
for (int i = 0; i < 12; i++) {
uint32_t col = c;
if (c) {
float f = spin ? 0.35f + 0.65f * (0.5f + 0.5f * sinf((ph + i * 25) * 0.15f))
: 0.55f + 0.45f * sinf(ph * 0.06f); // 呼吸
uint8_t r = red(c) * f, g = green(c) * f, b = blue(c) * f;
col = ring.Color(r, g, b);
}
ring.setPixelColor(i, col);
}
ring.show();
}
/* ------------------------------------------------------------
* micOn —— 實體靜音偵測
* 回傳:MIC 電源軌有電與否
* 為什麼讀電壓而非信 GPIO:開關串的是 VDD,軟體無從「繞過」,
* 這就是物理隱私的實現方式——開關壞了這裡也會回報異常
* ------------------------------------------------------------ */
bool micOn() { return analogReadMilliVolts(PIN_MICSENSE) > 1600; }
/* ------------------------------------------------------------
* record —— 錄一句話到 PSRAM(能量 VAD 尾端點)
* 回傳:樣本數;0 = 失敗
* 做法:連續取樣算 RMS;持續 silence 或達上限即停;
* 開錄前已喊過喚醒詞,因此不留頭部静默
* ------------------------------------------------------------ */
size_t record() {
recLen = 0; recMax = 8 * SAMPLE_HZ;
if (!recBuf) recBuf = (int16_t*)ps_malloc(recMax * sizeof(int16_t));
uint32_t t0 = millis(), silentMs = 0;
while (millis() - t0 < MAX_REC_MS) {
int16_t s;
if (!i2sMic.read((uint8_t*)&s, 2, 100)) continue;
recBuf[recLen++] = s;
float rms = fabsf(s / 32768.0f);
silentMs = (rms > VAD_LEVEL) ? 0 : silentMs + 1;
if (silentMs > (uint32_t)(SAMPLE_HZ * VAD_SILENCE / 1000) && recLen > SAMPLE_HZ) break;
}
return recLen;
}
/* ------------------------------------------------------------
* sendWavToGateway —— WAV 打包上傳,取回 {text, reply}
* 參數:textOut/replyOut = 回寫緩衝
* 回傳:是否成功(3s 逾時;NAS 掛了走離線降級)
* ------------------------------------------------------------ */
bool sendWavToGateway(char* textOut, char* replyOut, size_t cap) {
HTTPClient http;
http.begin(String(GATEWAY) + "/asr_say");
http.addHeader("Content-Type", "application/octet-stream");
http.setTimeout(15000);
// 自組 44 位元組 WAV 頭 + PCM(省一個檔案庫)
uint32_t bytes = recLen * 2, total = 36 + bytes;
uint8_t hdr[44] = {'R','I','F','F', total&0xFF,(total>>8)&0xFF,(total>>16)&0xFF,(total>>24)&0xFF,
'W','A','V','E','f','m','t',' ',16,0,0,0, 1,0, 1,0,
SAMPLE_HZ&0xFF,(SAMPLE_HZ>>8)&0xFF,(SAMPLE_HZ>>16)&0xFF,(SAMPLE_HZ>>24)&0xFF,
SAMPLE_HZ*2&0xFF,(SAMPLE_HZ*2>>8)&0xFF,(SAMPLE_HZ*2>>16)&0xFF,(SAMPLE_HZ*2>>24)&0xFF,
2,0, 16,0, 'd','a','t','a', bytes&0xFF,(bytes>>8)&0xFF,(bytes>>16)&0xFF,(bytes>>24)&0xFF};
if (http.sendRequest(hdr, 44) < 0) { http.end(); return false; }
if (http.getStream().write((uint8_t*)recBuf, bytes) != bytes) { http.end(); return false; }
int code = http.GET();
bool ok = (code == 200);
if (ok) { // gateway 以 "text\nreply" 純文字回,省 JSON 解析庫
String body = http.getString();
int nl = body.indexOf('\n');
strncpy(textOut, body.substring(0, max(nl, 0)).c_str(), cap - 1);
strncpy(replyOut, body.substring(nl + 1).c_str(), cap - 1);
}
http.end();
return ok;
}
/* ------------------------------------------------------------
* localCommand —— 本地指令直通(不走 LLM,<300ms)
* 參數:text = 辨識文字
* 回傳:true = 已攔截並生成回答
* 為什麼:常用指令佔實際使用 80%,讓它們不經 7B 模型,
* 整機體驗從「能用」變「跟手」
* ------------------------------------------------------------ */
bool localCommand(const char* text, char* reply, size_t cap) {
if (strstr(text, "幾點") || strstr(text, "時間")) {
struct tm t; time_t now = time(nullptr); localtime_r(&now, &t);
snprintf(reply, cap, "現在%E9%9F%93%E5%9C%8B時間 %d 點 %d 分", 0); // 占位防誤
snprintf(reply, cap, "現在 %d 點 %d 分", t.tm_hour, (t.tm_min / 5) * 5);
return true;
}
if (strstr(text, "NAS") || strstr(text, "nas")) {
strcpy(reply, "NAS 運轉正常,磁碟使用七成。"); // 進階:真打 SMB API
return true;
}
return false; // 「計時 N 分鐘」留給讀者的 exercise(正則抽數字 + Timer)
}
/* ------------------------------------------------------------
* playTts —— 請 gateway 合成語音並用 I2S#2 播放
* 參數:text = 要唸的文字
* 注意:播放期間凍結 MIC(簡單 AEC),防自我迴錄
* ------------------------------------------------------------ */
void playTts(const char* text) {
app = PLAY; setLed(ring.Color(0, 0, 255), false);
digitalWrite(PIN_AMP_SD, HIGH); // 功放 enabled
HTTPClient http;
http.begin(String(GATEWAY) + "/tts?text=" + http.urlEncode(text));
http.setTimeout(12000);
if (http.GET() == 200 && http.getSize() > 44) {
WiFiClient* s = http.getStreamPtr();
s->readBytes((uint8_t*)recBuf, 44); // 丟 WAV 頭(固定 16k mono)
uint8_t chunk[2048];
while (s->available() && app == PLAY) {
int n = s->readBytes(chunk, sizeof(chunk));
if (n <= 0) break;
i2sAmp.write(chunk, n); // 阻塞寫,天然節流
}
}
http.end();
digitalWrite(PIN_AMP_SD, LOW); // 靜音,消底噪
}
/* ------------------------------------------------------------
* setup —— 雙 I2S + esp-sr 喚醒引擎
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
pinMode(PIN_AMP_SD, OUTPUT); digitalWrite(PIN_AMP_SD, LOW);
ring.begin();
analogReadResolution(12);
i2sMic.begin(I2S_MODE_STD, PIN_MIC_SCK, PIN_MIC_WS, PIN_MIC_SD, I2S_PIN_NO_CHANGE);
i2sMic.setClock(SAMPLE_HZ); i2sMic.setSlot(I2S_STD, 32, 16); // INMP441: 32bit slot 取 16
i2sAmp.begin(I2S_MODE_STD, PIN_AMP_BCLK, PIN_AMP_LRC, I2S_PIN_NO_CHANGE, PIN_AMP_DIN);
i2sAmp.setClock(SAMPLE_HZ);
// esp-sr:中文喚醒詞(hilexin 系);Detection 閘門上調可壓誤喚
ESP_SR.setMinimumFeeback(0.2f);
ESP_SR.begin(NULL, SPEECH_MODEL_LIST_WAKE_NET9L_CN_HILEXIN);
ESP_SR.observe(); // 背景任務常駐喚醒偵測
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
configTime(8 * 3600, 0, "192.168.8.120", "pool.ntp.org"); // NAS 先當校時源
}
/* ------------------------------------------------------------
* loop —— 狀態機推進;任何狀態:靜音開關 > 一切
* ------------------------------------------------------------ */
void loop() {
setLed(app == IDLE ? ring.Color(0, 120, 60) :
app == LISTEN ? ring.Color(0, 180, 180) :
app == THINK ? ring.Color(255, 140, 0) :
app == PLAY ? ring.Color(0, 0, 255) : 0, app == THINK);
if (!micOn()) { app = MUTED; setLed(0); delay(200); return; } // 物理開關優先
if (app == MUTED) app = IDLE;
static sr_cmd_t* cmd;
if (app == IDLE) {
cmd = ESP_SR.checkCommand(); // 非阻塞取喚醒事件
bool btn = digitalRead(PIN_BUTTON) == LOW;
if (cmd || btn) { app = LISTEN; appMs = millis(); record(); app = UPLOAD; }
}
if (app == UPLOAD) {
char text[160] = "", reply[320] = "";
bool offline = (WiFi.status() != WL_CONNECTED);
bool ok = !offline && sendWavToGateway(text, reply, sizeof(reply));
app = THINK;
if (ok && !localCommand(text, reply, sizeof(reply))) {
// reply 已由 LLM 生成
} else if (!ok) {
strcpy(reply, offline ? "Wi-Fi 離線,我先自己答。" : "NAS 沒回應,改天再問。");
if (localCommand(text, reply, sizeof(reply))) {} // 離線也盡量給答案
}
playTts(reply);
app = IDLE;
}
delay(2);
}
⚠️ 本機 LLM 的回答品質取決於 qwen2.5:7b 的量化檔與系統提示詞;把它當「60 字口語精靈」用,別指望深度問答——深度問題留給桌上的電腦,這是產品定位不是能力妥協。
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:4 個零件;腔體內壁噴封孔漆兩遍
- MIC 減震小板:INMP441 焊小pcb → 卡進上蓋減震座,聲孔對準導音柱
- 喇叭與功放:喇叭鎖腔體前板,OUT± 對正負;功放貼腔壁散熱
- 靜音開關接 VDD:船型開關串 3V3→MIC VDD,GPIO12 分壓並到開關後段
- 燒錄與喚醒校準:序列埠看 WakeNet score,調 ESP_SR 靈敏度到「1m 內三呼三中、電視聲不喚」
- NAS 三件套起棧:docker compose up -d,curl 測 /tts 出聲
- 端到端測試:「小天小天,現在幾點了」→ 應 < 3 秒開口
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 喚醒不靈 | MIC 吃到喇叭自我噪音/增益過高 | MIC 離喇叭 ≥8cm;MAX98357A GAIN 接 100k 到 GND 降增益 |
| 半夜自喚醒 | 電視剛好講到喚醒詞 | 提高 Detection 閘門 + 二次能量確認;喚醒詞避開日常語(別用「小白小紅」) |
| 錄音全是噠噠雜音 | USB 充電器接地迴路 | 換隔離電源或改筆電 USB 測試;線材絞距 |
| 中文辨識錯字多 | base 模型 + beam=1 | NAS 端換 small、beam=5;確認採樣率 16000 |
| 播放卡頓爆音 | 客戶端隔離/Wi-Fi 弱 | 關 AP 隔離;助理固定 IP,收訊位置挪到 5GHz AP 下 |
| 它回答自己的問題 | 播放時未凍結採集 | 確認 PLAY 狀態沒進 record();本程式以狀態機硬隔 |
🎯 進階延伸
- 多房間:2~3 台 S3 共用 NAS gateway,按房間路由對話上下文(gateway 加 room 參數)
- 延遲再壓 40%:NAS 端 silero-vad 判端點,S3 持續推流不等待
- 聲紋鎖:只認家人的聲音開行事曆/購物清單個人上下文(NAS 端加 resemblyzer)
- IR 發射模組:「關電燈」→ 韌體直推紅外,老電器無縫加入全內網生態
📦 檔案清單
voice-assistant/
├── src/
│ └── voice-assistant.ino # ESP32 韌體(本頁完整程式)
├── gateway/
│ ├── app.py # NAS FastAPI 閘道(ASR→LLM→TTS)
│ └── prompts.txt # 系統提示詞(60 字口語規則)
├── docker-compose.yml # 三件套起棧檔(本頁完整版)
├── stl/
│ ├── speaker-box.stl # 音箱腔體
│ ├── top-cover.stl # 上蓋+MIC 減震座
│ └── led-diffuser.stl # 燈環導光圈
├── f3d/
│ └── assistant-design.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件