📋 專案概述

上週五晚上做舒芙蕾,滿手蛋黃糊進行到「筛糖 40 克」這一步,食譜下一步是什麼?把手在圍裙上狂擦、解鎖手機、開筆記型 App——螢幕上四個指印,糖還撒了一檯面。廚房是最需要語音助理的房間,也是最不适合放智慧音箱的房間:

  1. 斷網變磚:颱風天斷網日,NT$1,500 的音箱連「現在幾點」都不肯報——它的腦在三千公里外的機房。
  2. 訂閱制:進階語音技能每月 99 元,一年繳完能買兩台本專案的主機。
  3. 隱私:你家的談話、小孩的童言童語、吵架的內容——全部躺在別人公司的伺服器上,隱私政策寫得再漂亮,資料就是不在你家。

本專案把架構反過來:喚醒詞離線跑在 ESP32-S3 本機(esp-sr WakeNet,斷網照樣聽得懂「小天小天」),語音辨識與大模型走 NAS 上的本地服務(faster-whisper + Ollama + edge-tts),音訊一個位元都不出家門:

為什麼選 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 N16R81~210 元務必 OPI PSRAM 版,QSPI 板跑不了大緩衝
麥克風INMP441 I2S1~35 元線長 ≤10cm,遠離喇叭
功放MAX98357A 3W I2S1~55 元B 類發熱屬正常
喇叭4Ω 3W 52mm 全頻1~45 元密閉腔體見列印設計
指示WS2812B 12 燈環1~25 元狀態呼吸燈
按鍵6×6 微動 ×22~10 元對話鍵/模式鍵
靜音開關船型開關(串 MIC VDD)1~10 元物理斷採集,本專案靈魂
電源5V 2.5A USB-C1~90 元喇叭峰值要夠,別用電腦 USB 孔湊合
列印材料PLA ~70g—~20 元音箱腔體
合計~690 元NAS 端既有算力不計

🔌 接線圖

ESP32-S3 N16R8(16MB+8MB) GPIO4/5/6 GPIO12 GPIO13 GPIO7/15/16 GPIO17 GPIO10 INMP441 SCK/WS/SD 橘線 ×3:SCK/WS/SD → GPIO4/5/6 紅線:VDD ← 船型靜音開關 ← 3V3 MAX98357A BCLK/LRC/DIN SD_MODE 青線 ×3:BCLK/LRC/DIN → GPIO7/15/16 紅線:SD_MODE → GPIO17(拉高才出聲) 燈環 綠線:GPIO10 →[330Ω]→ DIN 4Ω 3W 喇叭
▲ 雙 I2S 各走各的時鐘;靜音開關串在 MIC 的 VDD 上——開關一撥,麥克風物理斷電,韌體從電源軌偵測得到。

詳細接線

ESP32-S3 腳位連接說明
GPIO4 / 5 / 6INMP441 SCK / WS / SDI2S #1 專供麥克風;L/R 腳接地(左聲道)
GPIO12MIC VDD 電源軌偵測经 100k/100k 分壓讀 3V3 軌——靜音開關斷電時讀到 0,燈號同步熄滅
GPIO13對話按鍵 → GND內部上拉;按下等同喊喚醒詞
GPIO7 / 15 / 16MAX98357A BCLK / LRC / DINI2S #2 專供喇叭,與 MIC 完全隔離
GPIO17MAX98357A SD_MODE拉高才出聲;休眠拉低消背景嘶聲
GPIO10WS2812 環 DIN串 330Ω;5V 供電就近 1000µF
3V3 / 5VMIC+燈環 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)1PLA深綠~3.5 hr
上蓋(電子艙 + MIC 減震座)1PLA深綠~2 hr
燈環導光圈1PLA乳白~25 min
壁掛支架(可選)1PLA深綠~30 min

列印設定

設計要點

  1. 密閉腔 + 倒相孔:52mm 全頻需要一个 260cc 的腔;倒相孔 φ22×40mm 調諧 65Hz,人聲底厚不空心
  2. MIC 減震座:INMP441 焊在小板上,小板四角卡發泡墊——不減震的話喇叭低音會「搖麥克風」錄進轟轟聲
  3. MIC 聲孔朝前上 45°:對準站立視線方向,廚房操作檯距離 60~90cm 收音最好
  4. 靜音開關側置 + 狀態窗:開關撥到「靜音」時露出紅色標記,一眼可辨,不用猜

💻 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 組裝說明書
  1. 列印零件:4 個零件;腔體內壁噴封孔漆兩遍
  2. MIC 減震小板:INMP441 焊小pcb → 卡進上蓋減震座,聲孔對準導音柱
  3. 喇叭與功放:喇叭鎖腔體前板,OUT± 對正負;功放貼腔壁散熱
  4. 靜音開關接 VDD:船型開關串 3V3→MIC VDD,GPIO12 分壓並到開關後段
  5. 燒錄與喚醒校準:序列埠看 WakeNet score,調 ESP_SR 靈敏度到「1m 內三呼三中、電視聲不喚」
  6. NAS 三件套起棧:docker compose up -d,curl 測 /tts 出聲
  7. 端到端測試:「小天小天,現在幾點了」→ 應 < 3 秒開口

🔧 問題排除

問題原因解決方法
喚醒不靈MIC 吃到喇叭自我噪音/增益過高MIC 離喇叭 ≥8cm;MAX98357A GAIN 接 100k 到 GND 降增益
半夜自喚醒電視剛好講到喚醒詞提高 Detection 閘門 + 二次能量確認;喚醒詞避開日常語(別用「小白小紅」)
錄音全是噠噠雜音USB 充電器接地迴路換隔離電源或改筆電 USB 測試;線材絞距
中文辨識錯字多base 模型 + beam=1NAS 端換 small、beam=5;確認採樣率 16000
播放卡頓爆音客戶端隔離/Wi-Fi 弱關 AP 隔離;助理固定 IP,收訊位置挪到 5GHz AP 下
它回答自己的問題播放時未凍結採集確認 PLAY 狀態沒進 record();本程式以狀態機硬隔

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32-C2 智慧觸控洗手泡沫機 📚 系列索引 下一篇:ESP32-C3 書房在座狀態燈 →