📋 專案概述

三代同堂第二年,家裡的「呼叫系統」是吼的:三樓陽台曬被叫一樓吃飯,一樓廚房抽油煙機全開——吼到第四聲已經變吵架。長輩的智慧型手機學習曲線失敗:解鎖、找 App、等連線,等他們做完這套,菜涼了。對講機買過一對 400 元的兒童玩具:2.4G 私頻在透天厝只撐兩層,三樓收不到一樓,還天天被小孩帶去澡堂玩水。

智慧型手機不是解——它假設人人隨身帶機,但在家裡,長輩的手機在二樓充電、你的在書房桌上。家庭內部通訊的真正需求是「「地點無關的固定點」」:廚房牆上、客廳邊桌、父母床頭——走過去,按著,講。

本專案兩台 NT$470 的家用語音基地台,牆上一顆大蘑菇按鈕,按著講、放開送:

算盤:商用「Wi-Fi 對講廣播系統」都是大樓建商方案,NT$15,000 起、要佈線要主機;消費級藍牙對講機配對即斷、延遲半秒起。本機走家用 Wi-Fi+UDP,兩機 470 元,路由器 Wi-Fi 涵蓋之處即通話範圍——透天厝加一顆中繼 AP 就是全戶。

🩺 誠實聲明:本機走 2.4G Wi-Fi 家用網路,通話範圍=Wi-Fi 涵蓋範圍,不能拿去山區 遠足;ADPCM 8:1 壓縮的音質約等於對講機級清晰度(聽得懂人話、聽不出歌手的換氣)。定位是「屋內呼叫與短句對話」,不是 VoD 音質——這也正是對講機該有的樣子。

為什麼選 ESP32-S3?

需求S3 的對應
音訊採樣不卡網雙核:核 1 跑 I2S 採樣/編解碼、核 0 給 Wi-Fi/UDP 協定棧——音訊管線最怕的就是協定棧搶時序,雙核物理隔離
語音緩衝PSRAM 當播放環形緩衝池(8 秒語音只要 256KB),靜態 RAM 留給協定棧
麥克風+喇叭同時有兩組 I2S 控制器,輸入輸出各佔一組,時鐘互不打架
狀態燈環GPIO48 內接 WS2812(DevKitC 板載),外接燈環同腳位驅動
不需要的(攝像頭、BLE、Thread)本機是音訊專案不是影像專案——但 S3 是「有雙核+PSRAM+雙 I2S」的最小 ESP,沒有更省的選項

環境:arduino-esp32 core ≥ 3.0,開發板 ESP32S3 Dev Module(N8R2 以上),USB CDC On Boot: Enabled、PSRAM: OPI PSRAM。外部相依 Adafruit_NeoPixel ≥ 1.12(燈環)。

🧱 硬體清單(每機,共 ×2)

項目型號數量預估單價備註
主控板ESP32-S3 DevKitC-1 N8R22~110 元需 PSRAM 版(型號帶 R2)
麥克風INMP441 I2S2~40 元L/R 接 GND=左聲道
喇叭模組MAX98357A 3W I2S2~45 元GAIN 懸空=6dB
喇叭單體8Ω 2W 28mm2~20 元單聲道,對講夠用
PTT 按鈕24mm 蘑菇頭輕觸2~12 元按著講、放開送
燈環WS2812 8 環2~12 元省錢可先用板載那顆
列印料PLA 40g/機—~12 元天線淨空區禁金屬色絲
合計(兩機)~470 元兒童對講機的錢,全戶覆蓋

🔌 接線圖(S3,兩機相同)

ESP32-S3 DevKitC-1 N8R2 USB-C 5V(峰值 >1A 電源) GPIO12/14/13 ● GPIO1 (PTT) ● 3V3 / GND ● ● GPIO16/17/8 ● GPIO48 燈環 ● 5V / GND INMP441(I2S0) BCK=12 WS=14 SD=13・L/R→GND MAX98357A(I2S1) BCK=16 WS=17 SD=8・→8Ω 2W 喇叭 PTT 蘑菇頭 → GND WS2812 燈環(330Ω) 播放期間韌體硬閂麥克風=防嘯主防線
▲ 音訊接線三紀律:① MIC 與 SPK 走各自獨立的 I2S 控制器(I2S0/I2S1),時鐘互不干擾;② MAX98357A 峰值電流 600mA,供電線短且粗、與 S3 共地點要在板端匯聚;③ MIC 孔與喇叭出音口在殼體上背向——韌體硬閂是最後一道,聲學隔離是第一道。

ASCII 對照表

ESP32-S3 DevKitC 周邊(兩機接法相同) ┌──────────────┐ │ GPIO12 ●─────┼── INMP441 BCK ┐ │ GPIO14 ●─────┼── INMP441 WS ├─ I2S0 輸入 │ GPIO13 ●─────┼── INMP441 SD ┘ L/R→GND、VDD 3V3 │ GPIO16 ●─────┼── MAX98357A BCK ┐ │ GPIO17 ●─────┼── MAX98357A WS ├─ I2S1 輸出 → 8Ω 2W │ GPIO8 ●─────┼── MAX98357A SD ┘ GAIN 懸空 │ GPIO1 ●─────┼── PTT 蘑菇頭 → GND(內部上拉) │ GPIO48 ●─────┼── WS2812 DIN(330Ω,燈環 5V 獨立供) │ 3V3 ●─────┼── INMP441 VDD │ 5V/VIN ●─────┼── MAX98357A Vin(峰值 600mA,線徑 ≥0.75mm²) │ GND ●─────┼── 單點共地 └──────────────┘ ※ GPIO19/20 保留 USB、GPIO26-32 被 N8R2 閃存佔用——本韌體全部避開
⚠️ 燈環供電紀律:8 環 WS2812 全白滿亮 480mA,從 3V3 取會壓垮板載 LDO 讓 Wi-Fi 開機重啟——燈環 5V 獨立(或與喇叭分路),韌體預設亮度 60% 也是這個原因,別只當美觀設定。
👁️ 個資與隱私:本機是「按下才採樣」電路+韌體雙保險:未按 PTT 且 VAD 未觸發時,I2S 外設時鐘照常跑但韌體層直接丟棄樣本不進任何緩衝、不上網。怕萬一的可把 GPIO1 串到 MIC 的 VDD 做硬體斷電(進階章節)。

🖨️ 3D 列印零件(每機)

零件清單

零件數量材質建議顏色列印時間
桌面音箱座(35° 傾角、MIC 孔背向喇叭)1PLA任意~40 min
壁掛框(卡片式,3M 背膠免鑽)2PLA任意~15 min
PTT 蘑菇帽(加大掌心接觸面)2PLA+TPE 包邊更佳螢光橙~12 min
燈環_diffuser 導光圈(霧面聚光)2PLA 白(2 層牆 8% 填充透光)白~10 min
MIC 防風氈壓圈+喇叭導音腔(15mm 腔深)各 1PLA黑~15 min
走線夾 ×41 套PLA任意~5 min

聲學列印設定

💻 程式碼

📐 註解規範:區塊以框註分段;函式附框註(用途/參數/回傳);行內註解解釋「為什麼」。雙核管線:採樣/編解碼釘核 1,Wi-Fi 留核 0;ADPCM 8:1 壓縮;播放端 150ms 預緩衝換取串流順暢。
/* ============================================================
 *  ESP32-S3 家庭對講機  |  Home Intercom S3
 *  ------------------------------------------------------------
 *  功能:PTT 按住即說;IMAD-PCM 8:1 壓縮;UDP 串流播放
 *        (首包 <200ms);VAD 尾音門 300ms;播放時麥硬閂防嘯;
 *        WS2812 燈環四態;夥伴離線 Telegram 兜底;音量三段。
 *  硬體:ESP32-S3 DevKitC-1 N8R2 + INMP441(I2S0)
 *        + MAX98357A(I2S1) + 蘑菇 PTT(GPIO1) + WS2812(GPIO48)
 *  供電:USB-C 5V/2A(喇叭峰值 600mA 留裕度)
 *  相依:arduino-esp32 core >= 3.0;Adafruit_NeoPixel >= 1.12
 *  ============================================================ */
#include <WiFi.h>
#include <WiFiUdp.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>
#include "driver/gpio.h"

/* ------------------------------------------------------------
 * 1. 使用者設定區
 * ------------------------------------------------------------ */
const char* WIFI_SSID     = "YOUR_WIFI_SSID";      // 兩機須同一 2.4G SSID
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN  = "YOUR_BOT_TOKEN";      // 離線兜底用,可留空
const char* TG_CHAT_ID    = "YOUR_CHAT_ID";
const char* NODE_NAME     = "下樓";                // 自報家門(例:二樓)

const uint8_t PIN_PTT = 1, PIN_LED = 48;
const uint16_t PORT_RX = 8266;                     // 兩機同端口
uint8_t volLevel = 1;
const float volTable[3] = {1.0f, 0.7f, 0.4f};      // 高/中/夜間

Adafruit_NeoPixel ring(8, PIN_LED, NEO_GRB + NEO_KHZ800);
Preferences nvs;
WiFiUDP udp;

/* ------------------------------------------------------------
 * 2. 音訊管線:16kHz/16bit,雙 I2S 控制器
 * ------------------------------------------------------------ */
I2SClass i2sMic, i2sSpk;
const uint16_t SR = 16000;
const uint16_t FRAME = 160;                        // 10ms=160 樣本:延遲與流量的平衡點
int32_t rawBuf[FRAME];
int16_t pcmBuf[FRAME];

/* INMP441:32-bit slot、MSB 對齊——右移 14 位≈ 18-bit 動態壓回 16-bit,
   再乘 2 補回標準接法的 −6dB(資料表推薦位元位移法)*/
void micToPcm() {
  i2sMic.readBytes((char*)rawBuf, FRAME * 4);
  for (uint16_t i = 0; i < FRAME; i++) {
    int32_t v = rawBuf[i] >> 14;
    pcmBuf[i] = constrain(v * 2, -32768, 32767);
  }
}

/* ------------------------------------------------------------
 * 3. IMAD-PCM 編解碼(8:1,對講機級清晰度)
 *    為什麼不用 Opus:要 100KB 編碼器 RAM+授权費思維;
 *    ADPCM 40 行搞定,16kHz 人聲聽感差異在「聽得懂」之上不可分辨
 * ------------------------------------------------------------ */
const int16_t STEP[16] = {7,8,16,24,40,64,112,176,288,464,740,1184,1888,3008,4800,7680};
const int8_t  IDXT[8]  = {-1,-1,2,4,2,4,2,7};
int8_t encIdx = 0; int16_t encPred = 0;
uint8_t adBuf[FRAME / 2];                          // 160 樣本 → 80 bytes

uint8_t encodeNibble(int16_t samp) {
  int16_t diff = samp - encPred;
  int8_t nib = (diff < 0) ? 8 : 0;
  diff = (diff < 0) ? -diff : diff;
  int16_t step = STEP[encIdx], vp = step >> 1, mask = step;
  for (int8_t bit = 4; bit >>= 1; ) {              // 由高位到低位量化
    int16_t vp2 = vp + mask;
    if (diff >= vp2) { nib |= bit; diff -= vp2; }
    mask >>= 1; vp >>= 1;
  }
  encIdx = constrain(encIdx + IDXT[nib & 7], 0, 18);
  int16_t dq = ((nib & 8) ? -1 : 1) * (2 * vp + mask); // 重建預測值
  encPred = constrain(encPred + dq, -32768, 32767);
  return nib;
}
void adpcmEncode(int16_t* in, uint8_t* out, uint16_t n) {
  for (uint16_t i = 0; i < n; i += 2) {
    uint8_t hi = encodeNibble(in[i]), lo = encodeNibble(in[i + 1]);
    out[i / 2] = (hi << 4) | lo;
  }
}
int8_t decIdx = 0; int16_t decPred = 0;
int16_t decodeNibble(uint8_t nib) {
  int16_t step = STEP[decIdx], vp = step >> 1, mask = step;
  for (int8_t bit = 4; bit >>= 1; ) { int16_t vp2 = vp + mask; if (nib & bit) vp += vp2; mask >>= 1; vp >>= 1; }
  decIdx = constrain(decIdx + IDXT[nib & 7], 0, 18);
  decPred = constrain(decPred + ((nib & 8) ? -(2 * vp + mask) : (2 * vp + mask)), -32768, 32767);
  return decPred;
}
void adpcmDecode(uint8_t* in, int16_t* out, uint16_t n) {  // n=樣本數(偶)
  for (uint16_t i = 0; i < n; i += 2) {
    uint8_t b = in[i / 2];
    out[i] = decodeNibble(b >> 4); out[i + 1] = decodeNibble(b & 0xF);
  }
}

/* ------------------------------------------------------------
 * 4. 播放環形緩衝(PSRAM)+串流狀態
 * ------------------------------------------------------------ */
volatile int16_t* playBuf = nullptr;
const size_t PLAY_N = SR * 8;                      // 8 秒 ≈ 256KB,放進 PSRAM
volatile size_t pbHead = 0, pbTail = 0;
volatile bool streamActive = false;
volatile uint32_t lastRxMs = 0;                    // 逾時=對方講完了
volatile uint32_t peerOnlineMs = 0;                // 最近收到對方任何包

/* 單生產者/單消費者 ring 免鎖——head/tail 只被單邊遞增,
   未遞增差值恆小於一圈,無符號減法天然處理繞回 */
bool pbFull(size_t n)  { return (pbHead - pbTail) + n > PLAY_N; }
bool pbAvail(size_t n) { return  pbHead - pbTail >= n; }
/* 用途:寫入解碼後樣本(生產端:taskNet)。回傳:false=緩衝滿丟棄 */
bool pbWrite(int16_t* s, size_t n) {
  if (pbFull(n)) return false;
  for (size_t i = 0; i < n; i++) { playBuf[pbHead % PLAY_N] = s[i]; pbHead++; }
  return true;
}
bool pbRead(int16_t* s, size_t n) {
  if (!pbAvail(n)) return false;
  for (size_t i = 0; i < n; i++) { s[i] = playBuf[pbTail % PLAY_N]; pbTail++; }
  return true;
}

/* ------------------------------------------------------------
 * 5. VAD:能量門 + 300ms 尾音門
 *    為什麼要尾音:人講完最後一個字还有 200–300ms 的子音與呼吸,
 *    閂太快會把「…吃飯」咬成「…吃」——對講機最討厭的瑕疵
 * ------------------------------------------------------------ */
float rmsOf(int16_t* s, uint16_t n) {
  uint64_t acc = 0;
  for (uint16_t i = 0; i < n; i++) acc += (int32_t)s[i] * s[i];
  return sqrtf((float)(acc / n));
}
const float VAD_TH = 500.0f;                       // 安靜房間調低、廚房調高
uint32_t vadTailMs = 0;

/* ------------------------------------------------------------
 * 6. 送出任務(釘核 1:與 I2S 中斷同側,網棧留核 0)
 * ------------------------------------------------------------ */
String peerIp = ""; uint16_t peerPort = 8266;
bool pttHeld = false, txStartPending = false;
uint16_t txSeq = 0;

void taskMic(void* arg) {
  bool txOn = false;
  for (;;) {
    bool gate = pttHeld || (millis() < vadTailMs);  // PTT 或 VAD 尾音期
    micToPcm();
    float rms = rmsOf(pcmBuf, FRAME);
    if (rms > VAD_TH) vadTailMs = millis() + 300;   // 尾音門刷新
    if (gate && !txOn) { txOn = true; txStartPending = true; }
    if (!gate) txOn = false;
    if (txOn) {
      adpcmEncode(pcmBuf, adBuf, FRAME);
      uint8_t pkt[92];
      pkt[0] = 'I'; pkt[1] = 'C'; pkt[2] = '1';
      pkt[3] = txSeq & 0xFF; pkt[4] = txSeq >> 8;
      pkt[5] = txStartPending ? 0x01 : 0x00;        // START 旗標
      txStartPending = false;
      memcpy(pkt + 6, adBuf, FRAME / 2);
      udp.beginPacket(peerIp, peerPort);
      udp.write(pkt, 6 + FRAME / 2); udp.endPacket();
      txSeq++;
    } else vTaskDelay(2);                           // 未開麥讓出核 1 時間片
  }
}

/* ------------------------------------------------------------
 * 7. 接收任務(核 0 預設:跟 Wi-Fi 協定棧同側)
 * ------------------------------------------------------------ */
int16_t decBuf[FRAME];
void taskNet(void* arg) {
  for (;;) {
    int sz = udp.parsePacket();
    if (sz < 7 || sz > 92) { vTaskDelay(2); continue; }
    uint8_t pkt[96]; udp.read(pkt, sz);
    if (pkt[0] != 'I' || pkt[1] != 'C') continue;   // 雜音包丟掉
    lastRxMs = millis(); peerOnlineMs = millis();   // 任何包都刷新在線
    if (pkt[5] & 0x02) {                            // 文字包:CALL/心跳
      if (pkt[5] & 0x04) tgFallbackNotify();        // 對方CALL但我方離線兜底
      continue;
    }
    if (pkt[5] & 0x01) { pbHead = pbTail = 0; }     // START:清掉上一封殘留
    adpcmDecode(pkt + 6, decBuf, FRAME);
    pbWrite(decBuf, FRAME);
    streamActive = true;
  }
}

/* 播放任務(核 1):150ms 預緩衝吸收 jitter;300ms 無新包=結束 */
void taskSpk(void* arg) {
  int16_t out[FRAME];
  bool started = false;
  for (;;) {
    if (!streamActive) { vTaskDelay(5); started = false; continue; }
    if (!started) {
      if (pbHead - pbTail < SR * 150 / 1000) { vTaskDelay(2); continue; }
      started = true; i2sSpk.begin(I2S_PHILIPS_MODE, SR, 16);   // 播放期才開 AMP
    }
    if (pbRead(out, FRAME)) {
      float vol = volTable[volLevel];
      for (uint16_t i = 0; i < FRAME; i++) out[i] = (int16_t)(out[i] * vol);
      i2sSpk.write((char*)out, FRAME * 2);
    } else {
      memset(out, 0, sizeof out); i2sSpk.write((char*)out, FRAME * 2); // 補靜音防卡
      if (millis() - lastRxMs > 300) { streamActive = false; i2sSpk.end(); }
    }
  }
}

/* ------------------------------------------------------------
 * 8. 在線偵測 / Telegram 兜底
 * ------------------------------------------------------------ */
bool peerOnline() {
  return millis() - peerOnlineMs < 12000;            // 收過任何包(含心跳)視為在線
}
String urlencode(const String& s) {
  String o;
  for (unsigned int i = 0; i < s.length(); i++) {
    char c = s.charAt(i);
    if (isalnum(c) || c == '-' || c == '.' || c == ' ') o += c;
    else { char b[4]; sprintf(b, "%%%02X", (unsigned char)c); o += b; }
  }
  return o;
}
void tgPush(const String& m) {
  if (String(TG_BOT_TOKEN).length() < 10) return;
  HTTPClient h;
  h.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
  h.addHeader("Content-Type", "application/x-www-form-urlencoded");
  h.POST("chat_id=" + String(TG_CHAT_ID) + "&text=" + urlencode(m));
  h.end();
}
void tgFallbackNotify() {                            // 收到 CALL 但對方聽不到我→由我推播
  tgPush(String(NODE_NAME) + " 收到呼叫,但對方機器離線——請看 Telegram");
}

/* ------------------------------------------------------------
 * 9. 燈環 / 按鈕 / 音量
 * ------------------------------------------------------------ */
void ringShow() {
  static uint32_t drawMs = 0;                       // 20fps 更新:NeoPixel 每刷 80 bytes,沒必要每毫秒來
  if (millis() - drawMs < 50) return;
  drawMs = millis();
  uint32_t c;
  if (!peerOnline()) c = ring.Color(80, 0, 0);                       // 紅
  else if (pttHeld) c = ring.Color(255, 120, 0);                     // 橙:送出
  else if (streamActive) c = ring.Color(0, 160, 160);                // 青:收話
  else c = ring.Color(0, 120, 40);                                   // 綠:待機
  if (!peerOnline() && (millis() / 300) % 2) c = 0;                  // 離線閃
  for (uint8_t i = 0; i < ring.numPixels(); i++) ring.setPixelColor(i, c);
  ring.setBrightness(150);                                           // 59%:供電預算
  ring.show();
}

bool pttLast = true; uint32_t pttEdgeMs = 0;
void handlePtt() {
  bool now = gpio_get_level(PIN_PTT);
  if (now != pttLast && millis() - pttEdgeMs > 30) { // 30ms 消抖
    pttEdgeMs = millis();
    if (pttLast && !now) { pttHeld = true; }         // 按下
    else if (!pttLast && now) {                      // 放開:發 CALL(節點名 6 bytes)
      pttHeld = false;
      uint8_t t[12] = {'I','C','1',0,0,0x06};
      strlcpy((char*)t + 6, NODE_NAME, 6);
      udp.beginPacket(peerIp, peerPort); udp.write(t, 12); udp.endPacket();
    }
  }
  pttLast = now;
}

/* ------------------------------------------------------------
 * 10. setup / loop
 * ------------------------------------------------------------ */
void setup() {
  Serial.begin(115200);
  nvs.begin("intercom");
  peerIp = nvs.getString("peer", "");
  volLevel = nvs.getUChar("vol", 1);
  gpio_set_direction((gpio_num_t)PIN_PTT, GPIO_MODE_INPUT);
  gpio_pullup_en((gpio_num_t)PIN_PTT);

  playBuf = (volatile int16_t*)ps_malloc(PLAY_N * sizeof(int16_t)); // PSRAM 緩衝池
  i2sMic.setPins(14, 12, 13);   i2sMic.begin(I2S_PHILIPS_MODE, SR, 32); // ws,bck,sd
  i2sSpk.setPins(17, 16, 8);    /* begin 延後:AMP 只在播放期開 */
  udp.begin(PORT_RX);
  ring.begin(); ring.setBrightness(150); ring.show();

  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); }
  Serial.printf("\nIP=%s peer=%s (cmd: peer=192.168.1.x / vol=1 / stat)\n",
                WiFi.localIP().toString().c_str(), peerIp.c_str());

  xTaskCreatePinnedToCore(taskMic, "mic", 6000, nullptr, 6, nullptr, 1);
  xTaskCreatePinnedToCore(taskSpk, "spk", 6000, nullptr, 6, nullptr, 1);
  xTaskCreatePinnedToCore(taskNet, "net", 8000, nullptr, 5, nullptr, 0);
}

void loop() {
  handlePtt();
  ringShow();

  /* 心跳:每 5 秒告訴夥伴「我在」,也刷新對方燈環 */
  static uint32_t hbMs = 0;
  if (peerIp.length() > 7 && millis() - hbMs > 5000) {
    hbMs = millis();
    uint8_t t[6] = {'I','C','1',0,0,0x02};
    udp.beginPacket(peerIp, peerPort); udp.write(t, 6); udp.endPacket();
  }

  /* 序列指令 */
  if (Serial.available()) {
    static String buf; char ch = Serial.read();
    if (ch == '\n') {
      buf.trim();
      if (buf.startsWith("peer=")) { peerIp = buf.substring(5); nvs.putString("peer", peerIp); Serial.println("peer saved"); }
      else if (buf.startsWith("vol=")) { volLevel = constrain(buf.substring(4).toInt(), 0, 2); nvs.putUChar("vol", volLevel); Serial.println("vol saved"); }
      else if (buf == "stat") Serial.printf("ip=%s peer=%s online=%d stream=%d buf=%u ptt=%d\n",
          WiFi.localIP().toString().c_str(), peerIp.c_str(), peerOnline(), streamActive,
          (unsigned)(pbHead - pbTail), pttHeld);
      buf = "";
    } else buf += ch;
  }
  vTaskDelay(1);
}
🔌 雙核紀律:mic/spk 釘核 1、net 釘核 0 是時序隔離不是性能優化——I2S DMA 中斷預設在核 0,把採樣任務放核 1 讓 Wi-Fi 協定棧的長中斷搶不到時鐘,這才是首包 200ms 的真正來源。改單核跑(core 3.x 允許但難看)延遲會抖到 500ms+。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作桌 → 開啟 HTML 組裝說明書
  1. 按鈕帽與機殼:先卡蘑菇頭按鈕再鎖主板——裝完板才發現按鈕孔偏 2mm 是這組裝的傳統事故
  2. 聲學密封:MIC 防風氈壓圈、喇叭導音腔熱熔膠全周密封——漏聲的腔體等於沒腔體
  3. 兩台配對:序列埠 peer=192.168.1.X 互填對方 IP(路由器綁定靜態 lease,IP 漂了燈環變紅閃就知道)
  4. 擺位防嘯:兩機錯層或間距 ≥1.5m;同層對講先試夜間音量
  5. 驗收:連續三通不丟包;拔電夥伴機測 Telegram 兜底推播送達

🔧 問題排除

問題原因解決方法
完全收不到兩台不同 Wi-Fi/頻段stat 看 peer 與 online;確認同一 2.4G SSID(Mesh 路由的漫遊分區要關閉 AP 隔離)
聲音破音失真I2S 時鐘接錯/位元槽錯BCK/WS 對調試一次;INMP441 位移量改 >> 12 或 >> 16 各試一次(不同批次出貨位元對齊有差)
只有一台會講PTT 卡住/上拉失效stat 看 ptt 態是否恆 0;按鈕兩腳先短路測試開機自檢
通話中斷續2.4G 頻道擁塞路由器換頻道;或把 FRAME 加到 320 減半封包率(代價是延遲 +10ms)
尖嘯兩機距離太近播放時麥已硬閂;再拉開 1m 或降夜間音量——物理永遠優先於韌體
Wi-Fi 開機重啟循環燈環從 3V3 取電壓垮 LDO燈環改 5V 分路供電;亮度保持 ≤150
燈環恆綠但講不通peer IP 過期(DHCP 重新分配)路由器綁定 MAC→靜態 lease;两台都要重發 peer=

🎯 進階延伸

📦 檔案清單

home-intercom/
├── src/
│   └── home-intercom.ino    # 本頁完整韌體(兩機燒同一份,NODE_NAME 改字串)
├── stl/
│   ├── deskstand.stl        # 桌面音箱座(35°)
│   ├── wallframe.stl        # 壁掛框(卡片式)
│   ├── ptt-cap.stl          # 蘑菇帽
│   ├── ring-diffuser.stl    # 燈環導光圈
│   ├── mic-gasket.stl       # MIC 防風氈壓圈
│   └── speaker-chamber.stl  # 喇叭導音腔
├── f3d/
│   └── home-intercom.f3d    # Fusion 360 原始檔
├── manual/
│   └── assembly.html        # 圖文組裝說明書
└── README.md

🎯 結語

裝機那天下樓吃飯,阿嬤第一次没讓我「用手機傳訊息給你爸」。她按下橙色蘑菇、綠環變橙、對著它講「吃飯了」,一樓廚房那台亮青燈嗶一聲回話——整套動作比她按電子鍋複雜不了多少。後來她養成新習慣:飯菜上桌、按一顆、走人。好的家用科技不是讓人學會新東西,是把舊動作(按一顆按鈕)做得剛好够用。這系列寫到第 37 篇,我們做過會看鳥的、會量輻射的、會曬樹脂的——但這台是最常聽到「欸,這個好用」的一台。大概因為它解決的從來不是技術問題,是「三樓到一樓那 20 公尺,人喊破喉嚨、手機假裝沒看見」的距離。

← 上一篇:ESP32-C2 樹脂後固化轉台 📚 系列索引 下一篇:ESP32-C3 迷你桌上型示波器 →