📋 專案概述

六個月的娃只有「連續低頻嘶聲」能睡:安全座椅能睡、推車能睡、浴廁排氣風扇下也能睡——唯獨床上不行,因為你家夜裡的安靜跟車裡差一個世界:冰箱壓縮機每隔 40 分鐘轟然啟動、樓上 22:30 的拖動椅子聲、你太太翻個身床架吱呀一聲——每一聲對大人是背景,對淺眠的嬰兒是開戰宣言。你下載白噪音 App:20 分鐘後跳出「升級解鎖完整雨聲」的訂閱窗(NT$990/年,年度方案比實體機還貴);手機放床頭,第三天充電線就被扯壞一條。買嬰兒房品牌聲機:NT$2,400,六段出廠取樣、音量只有四格、最關鍵的是它不懂淡出——定時到點「咔」一聲掐斷,四分鐘後你聽到了那個熟悉的、被嚇醒的哭腔。你半夜摸黑起來關機器的次數,比它替你省的睡眠計時器錢多得多。

市售商品:

本專案的哲學:噪音是最好合成的東西——一個好的聲景不需要任何取樣檔。五種聲景全部由演算法即時生成(跑 20 年也不會聽到「接縫」,因為它根本不會重複),加上一台真正懂睡覺的機器該有的修養:

💰 成本效益:全機 ~NT$585。App 訂閱一年 NT$990;嬰兒聲機最低款也是本機 4 倍價。而真正值錢的是「淡出」讓孩子在淺眠週期不被嚇醒的那 0.3 個睡回籠覺/週。

為什麼選 ESP32-S3?

需求S3 的優勢
音頻不能遲到雙核:音頻 fill task 以 FreeRTOS 最高優先級綁核 1,Wi-Fi/BT 協議棧全部趕去核 0——單核的 C3/C6 在 Wi-Fi 掃描瞬間就是爆音預支
觸控互動S3 內建 14 通道電容觸控(GPIO1-14),音量 pad 直接長在外殼上,無外加觸控晶片
留了後路N16R8 的 PSRAM 養得起將來的 TinyML 哭鬧偵測自動開機、本地語音「音量小聲點」
靜音要真靜MAX98357A 的 SD 腳可由 GPIO 硬mute——淡出到零之後連功放的靜態嘶聲都收掉

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-S3 DevKitC-1 N16R81~230 元雙核+觸控+PSRAM
功放MAX98357A 3W I2S 模組1~60 元GAIN 腳懸空=6dB;接 GND=9dB(本機接 GND)
喇叭4Ω 3W 40mm 全頻1~40 元短音圈軟懸邊——低頻噪音不糊
螢幕SSD1306 0.96" I2C1~90 元夜間 8% 亮度模式
電源5V 2A 線性 adaptor1~80 元開關電源的高頻紋波在深夜=蟬鳴
列印材料PLA ~90g+吸音棉—~25 元室內機,PLA 可
小件防振矽膠腳×4、100µF/25V、開關帽—~60 元100µF 併功放電源端
合計~585 元品牌嬰兒聲機的 24%

🔌 接線圖

ESP32-S3 core1=音頻 core0=Wi-Fi GPIO12/13/14 I2S GPIO10 SD 靜音 GPIO2 觸控音量 GPIO3 按鈕 GPIO8/9 OLED MAX98357A BCLK/WS/DIN ←12/13/14 SD ←GPIO10 VIN 5V+100µF ∥ GND 4Ω 3W 喇叭(放大器輸出端子) SSD1306 SDA/SCL←8/9 銅箔觸控 pad ←GPIO2(貼殼內側) 按鈕→GND 功放 VIN 用獨立線回 adaptor,與板端共地單點匯接 I2S 三線 ≤12cm;喇叭線與 I2S 分兩側走
▲ 全機只有 8 根訊號線——音頻的勝負不在接線,在供電純淨度與 DMA 準時性。

詳細接線

ESP32-S3 腳位連接說明
GPIO12 / 13 / 14MAX98357A BCLK / WS / DINI2S 標準輸出 44.1kHz・16bit・立體聲
GPIO10MAX98357A SD靜音腳:淡出到零後硬 mute 功放底噪
GPIO2銅箔觸控 pad(殼內側)S3 touch:輕觸音量+1 檔、滿格回零
GPIO3按鈕(對 GND)短按切聲源(淡入淡出)、長按循環定時
GPIO8 / GPIO9OLED SDA / SCL夜間 8% 亮度
5V / GND線性 adaptor功放電源端併 100µF;地單點匯接
ESP32-S3 DevKitC-1 MAX98357A 喇叭 GPIO12(BCLK) ────────── BCLK GPIO13(WS) ────────── WS GPIO14(DOUT) ────────── DIN GPIO10 ────────── SD(靜音) 5V(獨立線) ──┬───────── VIN(+100µF∥) GND ─────────┴───────── GND / OUT± ────── 4Ω 3W GPIO2 ←銅箔 pad(音量) GPIO3 ←按鈕→GND GPIO8/9 ──── OLED SDA/SCL
⚠️ 功放電源不要從板子 3V3 拿:3W 滿輸出 時峰值近 0.6A,會把整個板子的地紋波抬高(表現為「音量越大越有嗡嗡聲」)。VIN 走獨立線回 adaptor,地單點匯接。

🖨️ 3D 列印設計

零件材質/設定數量備註
shell-top 頂蓋PLA/0.2mm/2 壁/15%1蜂窩穿孔開孔率 32%——開孔率就是音量頻響曲線
diffuser-dome 擴散穹頂PLA/0.2mm/1.2 壁/0%(實壁薄殼)1喇叭正上方的半球:高頻不「指著你」而是散開
shell-base 底座PLA/0.2mm/2.5 壁/25%1四角矽膠腳槽+纜線出孔+OLED 12° 傾窗
pad-plate 觸控壓片PLA/0.2mm/2 壁/20%1銅箔倉,壓片厚度決定觸控靈敏度
bottom-vent 底透音條PLA/0.24mm/2 壁/10%1棕噪的低頻需要下方出音口才厚

列印總重 ~90g。殼內壁貼一半面積吸音棉(對角線分區:一半吸高頻毛刺、一半留反射)——「雨聲裡的那層沙沙」有沒有,就差這一步。銅箔觸控 pad 與外殼距離 ≤2.5mm,超了靈敏度不夠就貼一片鋁箔到殼外應急。

💻 Arduino 程式碼

Arduino IDE 2.x+espressif 3.x core(開發板 ESP32S3 Dev Module,PSRAM 選 OPI);I2S 直接用 core 內建 ESP-IDF i2s_std 驅動,函式庫僅 Adafruit SSD1306+GFX。

/* ============================================================
 *  白噪音睡眠聲機  sleep-sound.ino
 *  ------------------------------------------------------------
 *  功能:五種程序化聲景、觸控音量、睡眠定時 30s 淡出、
 *        夜間減亮、NTP 時鐘、NAS 週報
 *  硬體:ESP32-S3 DevKitC-1|I2S: 12=BCLK 13=WS 14=DOUT
 *        10=功放 SD、2=觸控音量、3=按鈕、8/9=I2C OLED
 *  供電:5V 線性 adaptor,功放電源獨立線+100µF
 *  相依:Adafruit_SSD1306 / GFX(core 3.x,i2s_std API)
 * ============================================================ */
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include "driver/i2s_std.h"

/* --- 0. 配置 --- */
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASS";
const char* NAS_URL   = "http://192.168.8.60:1880/sleep-sound";
const char* NAS_TOKEN = "change-me";

#define PIN_SD   10
#define PIN_TOUCH 2
#define PIN_BTN  3
#define SR 44100
#define FRAMES 512                    // 每緩衝 512 訊框 ≈11.6ms,低延遲低佔用

enum Mode { WHITE, PINK, BROWN, RAIN, WAVE, MODE_N };
const char* modeName[MODE_N] = {"WHITE", "PINK ", "BROWN", "RAIN ", "WAVE "};
Mode mode = PINK;
uint8_t volume = 10;                  // 0..16 檔(≈2dB/檔)
uint32_t timerEnd = 0;                // 定時結束 millis;0=未定時
const uint32_t FADE_MS = 30000;       // 淡出 30 秒——本機的修養
float fade = 1.0f;                    // 全域淡入淡出包絡
bool touching = false;
uint32_t lastUser = 0;                // 最近互動時刻(夜間減亮用)

/* --- 1. 噪聲生成器狀態 --- */
uint32_t xs = 0x9E3779B9;             // xorshift 種子
float pk[7] = {0};                    // Kellet 粉噪級聯
float brownLast = 0;
float dripEnv = 0, dripFreq = 0, dripNext = 0;   // 雨滴
float wavePhase = 0;

static inline float whiteF() {        // 白噪:xorshift32,比 rand 快一個數量級
  xs ^= xs << 13; xs ^= xs >> 17; xs ^= xs << 5;
  return (float)((int32_t)xs) / 2147483648.0f;
}
float pinkF() {                       // Paul Kellet 級聯近似 -3dB/oct
  float w = whiteF();
  pk[0] = 0.99886f*pk[0] + w*0.0555179f;   pk[1] = 0.99332f*pk[1] + w*0.0750759f;
  pk[2] = 0.96900f*pk[2] + w*0.1538520f;   pk[3] = 0.86650f*pk[3] + w*0.3104856f;
  pk[4] = 0.55000f*pk[4] + w*0.5329522f;   pk[5] = -0.7616f*pk[5] - w*0.0168980f;
  return (pk[0]+pk[1]+pk[2]+pk[3]+pk[4]+pk[5]+pk[6]+w*0.5362f) * 0.11f;
}
float brownF() {                      // 棕噪:漏積分器,-6dB/oct
  brownLast = (brownLast + 0.02f * whiteF()) / 1.02f;
  return brownLast * 3.5f;
}

/* --- 2. 聲景取樣器:每個聲源的「一個取樣」 ---
 * 參數:t 取樣序号|回傳:單聲道 -1..1
 * 為什麼即時合成:零取樣檔、永不重複循環、ROM 零佔用 */
float sampleAt(uint64_t t) {
  switch (mode) {
    case WHITE: return whiteF();
    case PINK:  return pinkF();
    case BROWN: return brownF();
    case RAIN: {
      float s = brownF() * 0.45f;                      // 雨幕底
      if (t > dripNext && dripEnv < 0.05f) {           // 泊松到點:新的一滴
        dripEnv = 0.5f + 0.5f * fabsf(whiteF());
        dripFreq = 900 + 1800 * fabsf(whiteF());
        dripNext = t + (uint64_t)(SR * (0.02f + 0.16f * fabsf(whiteF())));
      }
      dripEnv *= 0.9985f;                              // ~60ms 衰減
      s += dripEnv * 0.5f * sinf(6.2832f * dripFreq * t / SR);
      return constrain(s, -1, 1);
    }
    case WAVE: {
      wavePhase += 6.2832f * 0.07f / SR;               // 0.07Hz ≈ 每 14 秒一浪
      float swell = 0.4f + 0.35f * sinf(wavePhase);
      float s = brownF() * swell;
      if (sinf(wavePhase) > 0.75f) s += pinkF() * 0.10f;   // 浪頭嘶聲
      return constrain(s, -1, 1);
    }
    default: return 0;
  }
}

/* --- 3. 音頻填充任務:綁核 1、最高優先級 ---
 * 參數:arg 未用|回傳:無
 * 為什麼綁核:core0 是 Wi-Fi/BT 協議棧地盤;音頻與它們搶時間=爆音 */
i2s_chan_handle_t gI2s;
void audioTask(void*) {
  static int16_t buf[FRAMES * 2];
  uint64_t t0 = 0;
  size_t w;
  for (;;) {
    float fadeNow = fade;
    if (timerEnd && millis() > timerEnd) {             // 定時到點:30s 線性淡出
      fadeNow = constrain(1.0f - (millis() - timerEnd) / (float)FADE_MS, 0, 1);
      if (fadeNow <= 0.001f) { digitalWrite(PIN_SD, LOW); fade = 0; }  // 硬 mute
    }
    float g = (volume ? volume / 16.0f : 0.0f) * fadeNow * 26000.0f;
    for (int i = 0; i < FRAMES; i++) {
      float s = sampleAt(t0 + i);
      int16_t v = (int16_t)(s * g);
      buf[i*2] = v; buf[i*2+1] = v;                    // 單聲道複製成立體
    }
    t0 += FRAMES;
    i2s_channel_write(gI2s, buf, sizeof buf, &w, portMAX_DELAY);  // 寫滿即阻塞=天然節流
  }
}

/* --- 4. I2S 初始化(ESP-IDF std 驅動,core 3.x) --- */
void i2sInit() {
  i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
  cc.dma_desc_num = 6; cc.dma_frame_num = 256;         // 6×256 深 DMA:Wi-Fi 干擾緩衝池
  i2s_new_channel(&cc, &gI2s, NULL);
  i2s_std_config_t sc = {
    .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SR),
    .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
               I2S_SLOT_MODE_STEREO),
    .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = 12, .ws = 13,
                  .dout = 14, .din = I2S_GPIO_UNUSED },
  };
  i2s_channel_init_std_mode(gI2s, &sc);
  i2s_channel_enable(gI2s);
}

/* --- 5. 觸控音量:輕觸+1 檔、滿格回零;靜音腳跟隨 --- */
void onTouch() {
  static uint32_t last = 0;
  if (millis() - last < 400) return;                   // 消抖
  last = millis();
  lastUser = millis();                                 // 任何互動都喚醒面板
  volume = (volume + 1) > 16 ? 0 : volume + 1;
  if (volume > 0 && timerEnd == 0) digitalWrite(PIN_SD, HIGH);
}
/* --- 6. 按鈕:短按切聲源(淡出→換→淡入);長按循環定時 --- */
uint32_t btnHold = 0, btnLast = HIGH;
void buttonStep(uint32_t m) {
  uint8_t b = digitalRead(PIN_BTN);
  static uint32_t tBtn = 0;
  if (b == LOW && btnLast == HIGH) { btnHold = m; }
  if (b == HIGH && btnLast == LOW) {
    if (m - btnHold < 450) {                           // 短按:聲源淡切
      fade = 0; mode = (Mode)((mode + 1) % MODE_N);
      timerEnd = 0; digitalWrite(PIN_SD, HIGH);
      for (uint8_t i = 0; i <= 20; i++) { fade = i / 20.0f; delay(12); }  // 240ms 淡入
    } else if (m - btnHold > 800) {                    // 長按:15/30/45/60/關
      static const uint32_t mins[] = {15, 30, 45, 60, 0};
      static uint8_t k = 0; k = (k + 1) % 5;
      timerEnd = mins[k] ? m + mins[k] * 60000UL : 0;
      if (timerEnd) { fade = 1; digitalWrite(PIN_SD, HIGH); }
    }
  }
  btnLast = b;
}

/* --- 7. 面板與夜間減亮 --- */
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
void drawPanel(uint32_t m) {
  bool dim = (m - lastUser > 60000);
  oled.clearDisplay(); oled.setTextSize(1); oled.setCursor(0, 0);
  oled.printf("%s vol%2u %s\n", modeName[mode], volume,
              WiFi.status() == WL_CONNECTED ? "net" : "wifi..");
  if (timerEnd && m < timerEnd) oled.printf("timer %2lum%02lus\n",
      (timerEnd - m) / 60000UL, ((timerEnd - m) / 1000UL) % 60);
  else if (timerEnd) oled.printf("fading...\n");
  else oled.printf("no timer\n");
  struct tm ti; time_t now; time(&now); localtime_r(&now, &ti);
  oled.printf("%02d:%02d\n", ti.tm_hour, ti.tm_min);
  oled.display();
  /* 夜間減亮:setContrast 128→16(約 8% 亮度)——嬰兒房裡不該有燈泡 */
  static bool dimSet = false;
  if (dim != dimSet) { dimSet = dim; oled.setContrast(dim ? 16 : 128); }
}

/* --- 8. NAS 週報:每週一 07:00 上報本週聲景分佈 --- */
float hrs[MODE_N] = {0}; uint32_t hAcc = 0;
void pushWeekly() {
  if (WiFi.status() != WL_CONNECTED) return;
  HTTPClient h; h.begin(NAS_URL);
  h.addHeader("Content-Type", "application/json");
  h.addHeader("X-Device-Token", NAS_TOKEN);
  String b = "{";
  const char q = char(34);
  for (int i = 0; i < MODE_N; i++) {
    b += q; b += modeName[i]; b += q; b += ":";         // key 用聲源名,NAS 端免維護索引表
    b += String(hrs[i], 1); b += (i < MODE_N - 1) ? "," : "";
  }
  String body = "{"; body += q; body += "uptimeH"; body += q;
  body += ":" + String(millis()/3600000UL) + ",";
  body += q; body += "hrs"; body += q; body += ":" + b + "}}";
  h.POST(body);
  h.end();
  for (int i = 0; i < MODE_N; i++) hrs[i] = 0;
}

/* --- 9. setup --- */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_SD, OUTPUT); digitalWrite(PIN_SD, LOW);
  pinMode(PIN_BTN, INPUT_PULLUP);
  Wire.begin(8, 9); oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.setTextColor(SSD1306_WHITE);
  touchAttachInterrupt(PIN_TOUCH, onTouch, 40);        // 殼貼式:門檻親調
  i2sInit();
  xTaskCreatePinnedToCore(audioTask, "audio", 4096, NULL, 24, NULL, 1);
  digitalWrite(PIN_SD, HIGH);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  configTime(8 * 3600, 0, "pool.ntp.org");
  lastUser = millis();
}

/* --- 10. loop:UI 拍 50ms/小時統計/週日計 --- */
void loop() {
  static uint32_t t50 = 0, tHour = 0, tMon = 0;
  uint32_t m = millis();
  buttonStep(m);
  if (m - t50 >= 500) { t50 = m; drawPanel(m); }
  if (m - tHour >= 3600000UL) { tHour = m; hrs[mode] += (fade > 0.01 && volume) ? 1 : 0; }
  if (m - tMon >= 7UL * 24 * 3600 * 1000) { tMon = m; pushWeekly(); }
}
⚠️ 兩個坑:①touchAttachInterrupt 門檻(本機 40)受外殼材質與濕手影響大,上殼後一定要重調——靈敏過頭會自己加音量(枕頭誤觸)。②setContrast 若你的 Adafruit_SSD1306 版本沒有,改發 0x81 對比度指令(註解標明處);先確認能編譯再上機。
📦 NAS 端(Node-RED):每週一 07:05 收週報 → Telegram「本週聲景:粉噪 21h、雨聲 14h、海浪 2h——比上週少哭鬧時段 3 小時」;另可加規則:週報缺席(設備離線 26 小時以上)→「聲機是不是被你老婆收進抽屜了」。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印後處理:頂蓋蜂窩孔逐孔通針(支撐殘渣是殺手)、殼內一半面積貼吸音棉(對角分區)
  2. 功放前測:MAX98357A 先裸測——100µF 焊在模組電源端,GAIN 接 GND(9dB),SD 接 GPIO10 驗證靜音
  3. 喇叭減震:喇叭法蘭墊 2mm 泡棉圈鎖固——硬鎖底座的殼會跟共振
  4. 觸控 pad 製作:40×25mm 銅箔膠帶貼 pad-plate 內側、飛線 GPIO2,膠帶全包邊防潮
  5. 地線單點匯接:功放 GND 與板 GND 各走獨立線到 adaptor 地端子(星形),拒走板對板跳線
  6. 燒錄+爆音掃描: Serial 開著跑 10 分鐘,Wi-Fi 掃描時段(重連瞬間)側耳聽有無 tick——有則加深 DMA(dma_desc_num 6→8)
  7. 觸控門檻調校:上蓋狀態跑 touch 原始值觀察(函式庫附範例),閒置<15、隔殼觸>60,門檻設中間
  8. 淡出聽感驗收:定時 15 分鐘縮時版(改 FADE_MS 測)——淡出全程應聽不出「結束點」;聽得出=淡出太短,誠實加長

🔧 問題排除

問題原因解決方法
完全無聲SD 腳沒拉高(功放默認靜音)或 I2S 腳位錯SD 必須 HIGH 才有輸出——這是新手 90% 的「無聲」;再查 12/13/14 三線序
間歇 tick/pop 爆音音頻任務被 Wi-Fi 搶時間或 DMA 太淺確認任務已綁核 1 且優先級 24;dma_desc_num 6→8;仍發作就把 OLED 刷新降頻(I2C 中斷也搶)
音量越大越嗡嗡功放電源走了板端 3V3/地線走線繞路VIN 獨立線回 adaptor、地單點匯接、100µF 就近——三件套缺一不可
自己會加音量觸控門檻太靈/線纜耦合門檻調高、pad 飛線包地、殼旁別壓充電線;枕頭邊的機器加防誤觸(連觸兩次生效)
棕噪聽起來沒低頻全封閉殼+頂蓋孔太小,低頻悶死加底透音條(本設計留了槽);低頻要「漏」,高頻才要「散」
淡出後有輕微嘶聲功放淡出到 0 仍在線fade 歸零時 digitalWrite(PIN_SD, LOW) 硬 mute——本韌體已做,別註解掉
NTP 時間差 8 小時configTime 時區參數錯台北:configTime(8*3600, 0, "pool.ntp.org");倒數計時走 millis 不受影響,只有面板時鐘會錯

🎯 進階延伸

📦 檔案清單

sleep-sound/
├── src/
│   └── sleep-sound.ino         # ESP32 韌體(本頁完整程式)
├── nas/
│   └── node-red-flow.json      # 週報接收/離線告警
├── stl/
│   ├── shell-top.stl           # 蜂窩穿孔頂蓋
│   ├── diffuser-dome.stl       # 擴散穹頂
│   ├── shell-base.stl          # 減震底座
│   ├── pad-plate.stl           # 觸控壓片
│   └── bottom-vent.stl         # 底透音條
├── f3d/
│   └── sleepsound-design.f3d   # Fusion 360 原始檔
├── test/
│   └── touch-cal.ino           # 觸控原始值觀察
└── manual/
    └── assembly.html           # 圖文組裝說明書
← 上一篇:ESP32-C3 雷射雕刻機同步空氣輔助模組 📚 系列索引 下一篇:敬請期待 →