📋 專案概述

我爸今年 68 歲,每天早上 6 點半固定聽「清晨電台時間」。他的設備是一台 2003 年的收音機——天線折了三次用熱熔膠黏回去,調頻要轉半圈才找得到台,最近開始出現惱人的交流聲。我試過幫他買智慧型喇叭:「小愛同學,播放台北正聲」——它回「網路連線失敗,請檢查 Wi-Fi」。68 歲的人要對著一個圓柱體喊話、還要先幫它連 Wi-Fi、更新韌體、登入帳號。上週他認真地問我:「能不能做一個轉一下就有聲音、不會壞、不用充電、也不用跟我說話的收音機?」

這就是本專案的全部規格:一個旋鈕 = 8 個電台,按下去 = 開關,轉到底 = 音量。沒有 App、沒有帳號、沒有語音助理。查過市面上的「網路收音機」:

  1. 品牌 Wi-Fi 收音機(2,500~6,000 元):本機 App 難用到令人發怒,電台清單綁雲端——廠商哪天結束服務,你的收音機就變成磚。
  2. 傳統 FM 收音機(300 元):台北市区 FM 發射台逐年關台,到 2026 年只剩 5 個台還清晰,而且很多電台根本不再上 FM。
  3. 手機 + 藍牙喇叭:功能最強,但「要解鎖、要開 App、要選台、來電話會中斷」——對長輩是四道牆。

關鍵洞察:網路收音機的本質是「一個永遠在重連的 MP3 串流播放器」,這恰好是 ESP32-S3 + PSRAM 的甜蜜點。本專案總成本 540 元:

成本效益:品牌機 2,500 元起跳且電台清單綁雲端;本專案 540 元,電台清單存在本機 Flash——廠商倒閉、雲端熄燈都與你無關。

為什麼選 ESP32-S3(N16R8)?

需求S3 的優勢
音訊緩衝8MB PSRAM:串流 jitter buffer 可給到 512KB,Wi-Fi 抖動 2 秒聽不出來;經典 ESP32 內建 SRAM 摳摳算算只能給 32KB,斷線重試的間隙全露餡
解碼算力雙核 LX7 + SIMD 指令,MP3 44.1kHz 軟解佔用 < 35%;C3/C6 單核解 MP3 會跟 Wi-Fi 中斷搶時間
I2S 外設S3 有 2 組 I2S,本專案用 I2S0 輸出 MAX98357A;預留 I2S1 給 v2 的麥克風語音選台
Flash 16MB電台清單、開機音效、中文字型全塞得下,OTA 雙分區還有餘裕

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-S3-DevKitC-1(N16R8)1~180 元必須 R8(8MB PSRAM);R2 版緩衝會縮到聽得出卡頓
功放MAX98357A I2S 3W 模組1~55 元I2S 數位輸入免 DAC;GAIN 腳懸空 = 9dB 預設
喇叭4Ω 3W 方型喇叭 52×23mm1~45 元別用 8Ω:MAX98357A 在 4Ω 才推得出 3W
顯示SSD1306 0.96" I2C OLED1~75 元台名/音量/時間/緩衝狀態
選台旋轉編碼器(帶按鍵)1~18 元15 格/轉;一格一台的手感來自軟體限格,不是硬體
音量旋轉編碼器(帶按鍵)1~18 元第二顆按鍵 = 開關機;長按切睡眠定時
供電Type-C 5V 1A 充電器 + 板載 USB-C1~85 元S3 DevKitC 自帶 CP2102 + USB-C,直接供電
其他布網格、腳墊、M3 螺絲組、導光柱1 組~64 元布網格熱熔膠固定;導光柱把板載 LED 引到面板
合計~540 元木紋 PETG 耗材約 40 元另計(已含在列印時間)

🔌 接線圖

ESP32-S3 DevKitC N16R8 · 8MB PSRAM 音訊緩衝 GPIO17/16/15 GPIO10/11/12 GPIO41/40/39 GPIO13/14 MAX98357A + 4Ω 喇叭 BCLK→17 LRC→16 DIN→15 VIN=5V(喇叭接模組輸出) SD_MODE 拉高 = 常開 SSD1306 OLED SDA→41 SCL→40(0x3C) VCC=3V3 選台編碼器 A→10 B→11 SW→12 SW 內部上拉(按下=GND) 音量編碼器 A→13 B→14 SW→21 長按 = 睡眠定時 USB-C 5V 1A 直接供電,無額外電源 綠:I2S 音訊 + I2C 顯示 紫:全機單線供電
▲ 全機只有一條 USB-C 線;MAX98357A 吃 5V、3W 滿音量峰值約 600mA,DevKitC 板載 LDO 與 USB-C 供電線綽綽有餘。

詳細接線

ESP32-S3 腳位連接說明
GPIO17 / GPIO16 / GPIO15MAX98357A BCLK / LRC / DINI2S0 標準模式 16bit 44.1kHz;線長控制在 10cm 內,DIN 離板最近
MAX98357A VIN/GND板 5V / GNDSD_MODE 腳直接並到 VIN(常開),省一支 GPIO
GPIO41 / GPIO40OLED SDA / SCLI2C 標準腳位;0x3C
GPIO10 / GPIO11 / GPIO12選台編碼器 A / B / SWSW 內部上拉,按下 = GND
GPIO13 / GPIO14 / GPIO21音量編碼器 A / B / SW音量旋鈕軟體限 0~30 格
USB-C5V 1A 電源避開廉價「雙口 5V 共用 1A」的共享設計,音量開大會重啟
ASCII 對照表(S3-DevKitC 左列 / 右列)

  ┌──────────────────────────┐
  │ ESP32-S3 DevKitC-1       │
  │  GPIO17 ○──── MAX98357A BCLK
  │  GPIO16 ○──── MAX98357A LRC
  │  GPIO15 ○──── MAX98357A DIN
  │  GPIO41 ○──── OLED SDA        GPIO40 ○──── OLED SCL
  │  GPIO10/11/12 ○── 選台編碼器 A/B/SW(SW 內部上拉)
  │  GPIO13/14/21 ○── 音量編碼器 A/B/SW
  │  5V     ○──── MAX98357A VIN(SD_MODE 並 VIN)
  │  GND    ○──── 共地
  │  SPK+/SPK−(模組端)──── 4Ω 3W 喇叭
  └──────────────────────────┘
  全機單一 USB-C 5V 供電,無高壓迴路
⚠️ 喇叭線只接 MAX98357A 模組輸出端,絕對不要接回 ESP32 任何腳位;I2S 三條訊號線盡量短,與編碼器線束分兩側走,避免引擎聲(ground loop 雜訊)。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
音箱主體(斜面造型)1PETG 木紋木紋棕~160 min
前網罩框(夾布網格)1PETG 木紋木紋棕~40 min
旋鈕×2(選台/音量)2PETG黑~25 min
OLED 壓框 + 導光柱1 組PETG / 透明黑/透明~20 min
背板(螺絲固定)1PETG 木紋木紋棕~35 min

列印設定

設計要點

  1. 密閉腔 + 倒相孔:1.1L 密閉腔 + Ø18mm × 60mm 倒相管(調諧 78Hz),52mm 小喇叭靠倒相把低頻補到「播講清楚、音樂有底」——桌面收音機 80% 時間在人聲,這個取捨正確。
  2. 15° 斜面面板:OLED 與旋鈕在斜面上,從坐姿視線剛好平視;這是「擺在玄關也好看」的關鍵角度。
  3. 旋鈕軸承:編碼器鎖在內支架,軸穿過面板 Ø6.2mm 孔 + PTFE 墊圈,轉動不磨殼體。
  4. 免螺絲前網罩:框體壓布網格後卡進前緣止口(0.2mm 過盈),換布不用拆螺絲。

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 2.0.14(開發板選「ESP32S3 Dev Module」,PSRAM 選 OPI PSRAM)、ESP8266Audio ≥ 1.11(libretro-flow 版,含 HTTPS 支援)、Adafruit SSD1306 ≥ 2.5、ESP32Encoder ≥ 0.0.7、ArduinoJson ≥ 6.21。
/* ============================================================
 *  ESP32-S3 桌面網路收音機  |  Desktop Internet Radio
 *  ------------------------------------------------------------
 *  功能:8 電台旋鈕切換 + 串流斷線 3 秒自愈 + 睡眠定時淡出
 *        + NTP 時鐘 + 6:20 自動開機 + OLED 台名/電文顯示
 *  硬體:ESP32-S3 DevKitC-1 N16R8 + MAX98357A(4Ω 3W)
 *        + SSD1306 OLED + 旋編×2(選台/音量,皆帶按鍵)
 *  供電:USB-C 5V 1A(無高壓迴路)
 *  相依:Arduino core for ESP32 ≥2.0.14(板型 ESP32S3 Dev Module,
 *        PSRAM=OPI)/ ESP8266Audio / Adafruit_SSD1306
 *        / ESP32Encoder / ArduinoJson
 *  設計:长辈模式——無 App、無雲端、電台清單存本機 Preferences
 * ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Audio.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <ESP32Encoder.h>
#include <Preferences.h>
#include <time.h>

/* ------------------------------------------------------------
 * 1. 設定區
 * ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";

/* 電台清單:本機即來源真相(不綁雲端),改清單重燒即生效 */
struct Station { const char* name; const char* url; };
Station stations[] = {
  { "HPR 台北",   "https://stream.example.org/taipei.mp3" },
  { "News98",     "https://stream.example.org/news98.mp3" },
  { "Bravo 913",  "https://stream.example.org/bravo.mp3"  },
  { "IC之音",     "https://stream.example.org/ich.mp3"    },
  { "古典台",     "https://stream.example.org/classic.mp3"},
  { "爵士台",     "https://stream.example.org/jazz.mp3"   },
  { "BBC WS",     "https://stream.example.org/bbc.mp3"    },
  { "NHK R1",     "https://stream.example.org/nhk.mp3"    },
};
const uint8_t STATION_CNT = sizeof(stations) / sizeof(stations[0]);

/* ------------------------------------------------------------
 * 2. 腳位定義
 * ------------------------------------------------------------ */
#define I2S_BCLK  17
#define I2S_LRC   16
#define I2S_DIN   15
#define PIN_ST_A  10
#define PIN_ST_B  11
#define PIN_ST_SW 12
#define PIN_VOL_A 13
#define PIN_VOL_B 14
#define PIN_VOL_SW 21

/* ------------------------------------------------------------
 * 3. 執行期狀態
 * ------------------------------------------------------------ */
Preferences prefs;
AudioInfoMP3      info;
AudioStreamRemoteMP3 remote;
AudioGeneratorMP3 *generator = nullptr;
AudioOutputI2S    out(I2S_BCLK, I2S_LRC, I2S_DIN);  // 指定 I2S0 腳位,SD_MODE 常開
AudioConnection   patchCout1(remote, 0);   // 單鏈路:remote → 輸出
AudioControl      volumeCtl;

ESP32Encoder encStation, encVolume;
Adafruit_SSD1306 oled(128, 64, &Wire, 0);

uint8_t  stationIdx = 0;
uint8_t  volSteps = 22;                    // 0~30,開機讀回
bool     powered = false;
uint32_t powerOnMs = 0;
uint32_t sleepAtMs = 0;                    // 0 = 不定時
uint32_t lastEncStation = 0, lastEncVol = 0;
uint32_t swStationAt = 0, swVolumeAt = 0;  // 按壓時間戳(長按判定)
bool     streamBroken = false;
uint32_t retryAtMs = 0;                    // 斷線重試倒數
uint8_t  retryCount = 0;
bool     autoOpenedToday = false;          // 6:20 自動開機只觸發一次/日

/* ------------------------------------------------------------
 * volApply —— 把 0~30 格換算成 dB 套到輸出
 *   為什麼用 dB 而非線性:人耳對音量是對數感知,
 *   線性 22/30 格聽起來已經接近滿音量,低段全擠在一起
 * ------------------------------------------------------------ */
void volApply() {
  float db = map(volSteps, 0, 30, -48, 0) / 2.0f;   // -48~0 dB,0.5dB/格
  volumeCtl.volume(db, &out);
  prefs.putUChar("vol", volSteps);
}

/* ------------------------------------------------------------
 * startStream —— 播放當前電台
 *   回傳:成功 true;失敗由 loop 的 retry 機制接手
 *   為什麼先 stop:Generator 未釋放就 new 下一條會洩漏 PSRAM
 * ------------------------------------------------------------ */
bool startStream() {
  if (generator) { delete generator; generator = nullptr; }
  remote.stop();
  if (!remote.connect(stations[stationIdx].url)) return false;
  generator = new AudioGeneratorMP3();
  if (!generator->begin(remote, &out)) {
    delete generator; generator = nullptr;
    return false;
  }
  volumeCtl.volume(map(volSteps, 0, 30, -48, 0) / 2.0f, &out);  // 新串流重套音量
  streamBroken = false;
  retryCount = 0;
  powerOnMs = millis();
  return true;
}

/* ------------------------------------------------------------
 * stopStream —— 釋放播放鏈(關機/切台共用)
 * ------------------------------------------------------------ */
void stopStream() {
  if (generator) { generator->stop(); delete generator; generator = nullptr; }
  remote.stop();
}

void tgReconnect();   // 前向宣告:stationTick 內會呼叫

/* ------------------------------------------------------------
 * stationTick —— 選台旋鈕:一格一台 + 按鍵開關 + 長按重連
 *   為什麼限格:硬體 15 格/轉太細,一格一台要 15 格;
 *   軟體除以 15 取整,轉動有「卡、卡」的段落手感
 * ------------------------------------------------------------ */
void stationTick() {
  int32_t pos = encStation.getCount() / 15;
  if (pos != (int32_t)lastEncStation) {
    int8_t delta = (pos > (int32_t)lastEncStation) ? 1 : -1;
    lastEncStation = pos;
    if (!powered) return;
    stationIdx = (stationIdx + delta + STATION_CNT) % STATION_CNT;
    prefs.putUChar("st", stationIdx);      // 關機後記住最後收聽的台
    startStream();                          // 切台立即重連(斷線自愈同理)
  }
  // 按鍵:短按開關機、長按 2 秒強制重連(「卡住了壓一下」——長輩的唯一按鈕)
  bool sw = (digitalRead(PIN_ST_SW) == LOW);
  if (!sw) { swStationAt = 0; }
  else if (swStationAt == 0) { swStationAt = millis(); }
  else if (millis() - swStationAt > 2000) {
    swStationAt = -2000000;                // 已觸發長按:放開前不再觸發短按
    if (powered) { tgReconnect(); }
  }
}
void tgReconnect() { stopStream(); startStream(); }   // 無 Telegram:就地重連

/* ------------------------------------------------------------
 * volumeTick —— 音量旋鈕 + 短按開關 + 長按睡眠定時
 * ------------------------------------------------------------ */
void volumeTick() {
  int32_t pos = encVolume.getCount() / 2;  // 2 格 = 1 級
  if (pos != (int32_t)lastEncVol) {
    int8_t delta = (pos > (int32_t)lastEncVol) ? 1 : -1;
    lastEncVol = pos;
    volSteps = constrain((int8_t)volSteps + delta, 0, 30);
    volApply();
  }
  bool sw = (digitalRead(PIN_VOL_SW) == LOW);
  if (!sw) {
    if (swVolumeAt != 0 && millis() - swVolumeAt < 600) {   // 短按:開關機
      powered = !powered;
      if (powered) startStream(); else { stopStream(); sleepAtMs = 0; }
    }
    swVolumeAt = 0;
  } else if (swVolumeAt == 0) swVolumeAt = millis();
  else if (millis() - swVolumeAt > 1500) {                   // 長按:30→60→90→關
    swVolumeAt = -2000000;
    if (sleepAtMs == 0)          { sleepAtMs = millis() + 30UL * 60000; }
    else if (sleepAtMs <= millis() + 45UL * 60000) { sleepAtMs += 30UL * 60000; if (sleepAtMs > millis() + 90UL * 60000) sleepAtMs = 0; }
    else                         { sleepAtMs = millis() + 30UL * 60000; }
  }
}

/* ------------------------------------------------------------
 * streamHealth —— 串流健康檢查與 3 秒自愈
 *   為什麼 3 秒:多數基地台漫遊/ISP 抖動在 3 秒內恢復,
 *   立即重試反而丢緩衝;3 秒後重連,聽眾幾乎無感
 * ------------------------------------------------------------ */
void streamHealth(uint32_t now) {
  if (!powered || !generator) return;
  if (!generator->isRunning() && !streamBroken) {
    streamBroken = true;
    retryAtMs = now + 3000;
  }
  if (streamBroken && now >= retryAtMs) {
    retryCount++;
    if (retryCount > 5) {                 // 連續 5 次失敗:這個台可能掛了
      stationIdx = (stationIdx + 1) % STATION_CNT;   // 自動跳下一個台
      retryCount = 0;
    }
    startStream();
    retryAtMs = now + 3000UL * min(retryCount + 1, 4);  // 指數退避
  }
}

/* ------------------------------------------------------------
 * sleepFade —— 睡眠定時到點:停播關機(淡出留 v2 用音量斜坡)
 * ------------------------------------------------------------ */
void sleepFade(uint32_t now) {
  if (sleepAtMs == 0 || now < sleepAtMs) return;
  sleepAtMs = 0;
  powered = false;
  stopStream();
}

/* ------------------------------------------------------------
 * autoPowerOn —— NTP 時鐘 + 6:20 自動開機
 *   為什麼要 RTC-less:S3 靠 NTP 每 6 小時校時,斷網期間
 *   用 millis 推算,誤差 < 1 分鐘/日,對「開機聽新聞」夠用
 * ------------------------------------------------------------ */
void autoPowerOn() {
  struct tm t;
  if (!getLocalTime(&t, 0)) return;
  static int lastDay = -1;
  if (t.tm_mday != lastDay) { lastDay = t.tm_mday; autoOpenedToday = false; }
  if (!autoOpenedToday && !powered && t.tm_hour == 6 && t.tm_min == 20) {
    powered = true;
    startStream();
    autoOpenedToday = true;
  }
}

/* ------------------------------------------------------------
 * drawOled —— 播放中:台名+狀態;待機:時間+日期
 * ------------------------------------------------------------ */
void drawOled() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  struct tm t;
  bool haveTime = getLocalTime(&t, 0);
  if (powered) {
    oled.setTextSize(1);
    oled.setCursor(0, 0);
    oled.print(stations[stationIdx].name);
    oled.setCursor(0, 20);
    oled.print(streamBroken ? "RECONNECTING..." : ("VOL " + String(volSteps)));
    if (sleepAtMs) {
      oled.setCursor(0, 40);
      oled.print("SLEEP " + String((sleepAtMs - millis()) / 60000 + 1) + "m");
    }
    if (haveTime) {
      oled.setCursor(72, 40);
      char buf[6]; strftime(buf, sizeof(buf), "%H:%M", &t);
      oled.print(buf);
    }
  } else {
    oled.setTextSize(2);
    oled.setCursor(0, 10);
    if (haveTime) { char buf[6]; strftime(buf, sizeof(buf), "%H:%M", &t); oled.print(buf); }
    else oled.print("--:--");
    oled.setTextSize(1);
    oled.setCursor(0, 44);
    oled.print("STANDBY 8 STATIONS");
  }
  oled.display();
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  prefs.begin("radio", false);
  volSteps = prefs.getUChar("vol", 22);
  stationIdx = prefs.getUChar("st", 0) % STATION_CNT;

  pinMode(PIN_ST_SW, INPUT_PULLUP);
  pinMode(PIN_VOL_SW, INPUT_PULLUP);
  encStation.attachFullQuad(PIN_ST_A, PIN_ST_B);
  encVolume.attachFullQuad(PIN_VOL_A, PIN_VOL_B);
  encStation.setCount(0); encVolume.setCount(0);

  Wire.begin(41, 40);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) { delay(300); }
  configTime(8 * 3600, 0, "pool.ntp.org", "time.google.com");

  // 開機 3 秒倒數:長輩睜眼時間,6:20 自動開機後音樂已在
  volApply();
  Serial.println("Radio ready");
}

void loop() {
  uint32_t now = millis();
  static uint32_t lastOled = 0, lastClock = 0;

  stationTick();
  volumeTick();
  autoPowerOn();                            // 6:20 自動開機檢查(待機時也要跑)
  if (powered) {
    if (generator && generator->isRunning()) generator->loop();
    streamHealth(now);
    sleepFade(now);
  }
  if (now - lastClock > 6 * 3600000UL) { lastClock = now; configTime(8 * 3600, 0, "pool.ntp.org", "time.google.com"); }
  if (now - lastOled >= 500) { lastOled = now; drawOled(); }
  delay(2);
}
⚠️ 程式中 AudioStreamRemoteMP3 需使用支援 HTTPS 的 ESP8266Audio 分支(libretro-flow ≥1.11);電台 URL 一律以 curl -I 先驗證 Content-Type 為 audio/*,並遵守各電台的公開串流使用條款,僅供個人收聽。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:音箱主體、前網罩框、旋鈕×2、OLED 壓框 + 導光柱、背板(木紋 PETG)
  2. 腔體處理:內壁貼 5mm 止震棉(留倒相孔周圍)、倒相管熱熔膠固定
  3. 焊功放板:MAX98357A 三訊號線 + 5V/GND,喇叭線 22AWG 絞線
  4. 裝面板:編碼器鎖內支架、OLED 入壓框、導光柱對準板載 LED
  5. 入殼:主板螺絲固定、喇叭入前腔、線束理好不碰振膜
  6. 燒錄測試:PSRAM 選項確認 OPI、8 台全點一遍、斷 Wi-Fi 測自愈
  7. 交付儀式:教一次「轉一下有聲音、壓一下開關、卡住壓兩秒」——三句話,收工

🔧 問題排除

問題原因解決方法
完全無聲但 OLED 正常PSRAM 未啟用(緩衝配置失敗)或 I2S 腳位錯開發板 PSRAM 選 OPI PSRAM;確認 BCLK/LRC/DIN 對 17/16/15
聲音斷斷續續Wi-Fi 訊號弱或 jitter buffer 太小開機訊息看緩衝水位;訊號 < -65dBm 加中繼器
有嗡嗡引擎聲電源品質差(ground loop)換有認證的 5V 1A 電源;USB 線換粗芯短線
某台一直重連該電台 URL 已改版或需 HTTPS 憑證curl 驗證 URL;更新清單重燒;韌體會自動跳下一台
旋鈕轉了沒反應編碼器接 A/B 相反或彈跳交換 A/B;ESP32Encoder 全四倍頻已處理彈跳
6:20 沒自動開機NTP 校時失敗(斷網整夜)OLED 待機時間閃爍 = 未校時;查 Wi-Fi 或手動開機

🎯 進階延伸

📦 檔案清單

internet-radio/
├── src/
│   └── internet-radio.ino       # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── speaker-body.stl         # 音箱主體(斜面造型)
│   ├── grille-frame.stl         # 前網罩框
│   ├── knob.stl                 # 旋鈕
│   ├── oled-bezel.stl           # OLED 壓框
│   ├── light-pipe.stl           # 導光柱
│   └── back-plate.stl           # 背板
├── f3d/
│   └── internet-radio.f3d       # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32 智慧孵化器 📚 系列索引 下一篇:ESP32-C2 桌面智能加濕器 →