📋 專案概述
我爸今年 68 歲,每天早上 6 點半固定聽「清晨電台時間」。他的設備是一台 2003 年的收音機——天線折了三次用熱熔膠黏回去,調頻要轉半圈才找得到台,最近開始出現惱人的交流聲。我試過幫他買智慧型喇叭:「小愛同學,播放台北正聲」——它回「網路連線失敗,請檢查 Wi-Fi」。68 歲的人要對著一個圓柱體喊話、還要先幫它連 Wi-Fi、更新韌體、登入帳號。上週他認真地問我:「能不能做一個轉一下就有聲音、不會壞、不用充電、也不用跟我說話的收音機?」
這就是本專案的全部規格:一個旋鈕 = 8 個電台,按下去 = 開關,轉到底 = 音量。沒有 App、沒有帳號、沒有語音助理。查過市面上的「網路收音機」:
- 品牌 Wi-Fi 收音機(2,500~6,000 元):本機 App 難用到令人發怒,電台清單綁雲端——廠商哪天結束服務,你的收音機就變成磚。
- 傳統 FM 收音機(300 元):台北市区 FM 發射台逐年關台,到 2026 年只剩 5 個台還清晰,而且很多電台根本不再上 FM。
- 手機 + 藍牙喇叭:功能最強,但「要解鎖、要開 App、要選台、來電話會中斷」——對長輩是四道牆。
關鍵洞察:網路收音機的本質是「一個永遠在重連的 MP3 串流播放器」,這恰好是 ESP32-S3 + PSRAM 的甜蜜點。本專案總成本 540 元:
- 8 電台旋鈕切換:編碼器一格一個台,OLED 顯示台名與節目資訊(電台電文)
- 斷線自愈:串流斷線 3 秒內自動重連同一台;Wi-Fi 斷線自動重連,全部後台處理——長輩的世界裡「沒有重連按鈕」這回事
- 睡眠定時:長按音量鍵 30/60/90 分鐘後淡出關閉(我爸聽廣播入眠)
- NTP 時鐘:待機顯示時間,早上 6:20 自動開機預熱到他起床——「收音機自己醒來等他」
- 木紋外殼:PETG 木紋色列印 + 布網格前罩,放在玄關像台 60 年代的真空管收音機
- Type-C 供電:5V 1A 通用充電器,沒有了隨手換
成本效益:品牌機 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×23mm | 1 | ~45 元 | 別用 8Ω:MAX98357A 在 4Ω 才推得出 3W |
| 顯示 | SSD1306 0.96" I2C OLED | 1 | ~75 元 | 台名/音量/時間/緩衝狀態 |
| 選台 | 旋轉編碼器(帶按鍵) | 1 | ~18 元 | 15 格/轉;一格一台的手感來自軟體限格,不是硬體 |
| 音量 | 旋轉編碼器(帶按鍵) | 1 | ~18 元 | 第二顆按鍵 = 開關機;長按切睡眠定時 |
| 供電 | Type-C 5V 1A 充電器 + 板載 USB-C | 1 | ~85 元 | S3 DevKitC 自帶 CP2102 + USB-C,直接供電 |
| 其他 | 布網格、腳墊、M3 螺絲組、導光柱 | 1 組 | ~64 元 | 布網格熱熔膠固定;導光柱把板載 LED 引到面板 |
| 合計 | ~540 元 | 木紋 PETG 耗材約 40 元另計(已含在列印時間) |
🔌 接線圖
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO17 / GPIO16 / GPIO15 | MAX98357A BCLK / LRC / DIN | I2S0 標準模式 16bit 44.1kHz;線長控制在 10cm 內,DIN 離板最近 |
| MAX98357A VIN/GND | 板 5V / GND | SD_MODE 腳直接並到 VIN(常開),省一支 GPIO |
| GPIO41 / GPIO40 | OLED SDA / SCL | I2C 標準腳位;0x3C |
| GPIO10 / GPIO11 / GPIO12 | 選台編碼器 A / B / SW | SW 內部上拉,按下 = GND |
| GPIO13 / GPIO14 / GPIO21 | 音量編碼器 A / B / SW | 音量旋鈕軟體限 0~30 格 |
| USB-C | 5V 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 音箱主體(斜面造型) | 1 | PETG 木紋 | 木紋棕 | ~160 min |
| 前網罩框(夾布網格) | 1 | PETG 木紋 | 木紋棕 | ~40 min |
| 旋鈕×2(選台/音量) | 2 | PETG | 黑 | ~25 min |
| OLED 壓框 + 導光柱 | 1 組 | PETG / 透明 | 黑/透明 | ~20 min |
| 背板(螺絲固定) | 1 | PETG 木紋 | 木紋棕 | ~35 min |
列印設定
- 材質:木紋 PETG——桌面擺件第一優先是好看;木紋色層紋在斜面上有木紋質感,免後處理
- 音箱主體:壁厚 2.4mm(3 周)、填滿 10%——音箱不需要強度需要質量,內部再貼止震棉
- 旋鈕:填滿 60%(手感要沉),側面貼 3mm 鋁圓片增加轉動慣量
- 層厚:主體 0.2mm、旋鈕 0.16mm;前網罩框 0.24mm 寬線寬夾 0.5mm 網孔布
設計要點
- 密閉腔 + 倒相孔:1.1L 密閉腔 + Ø18mm × 60mm 倒相管(調諧 78Hz),52mm 小喇叭靠倒相把低頻補到「播講清楚、音樂有底」——桌面收音機 80% 時間在人聲,這個取捨正確。
- 15° 斜面面板:OLED 與旋鈕在斜面上,從坐姿視線剛好平視;這是「擺在玄關也好看」的關鍵角度。
- 旋鈕軸承:編碼器鎖在內支架,軸穿過面板 Ø6.2mm 孔 + PTFE 墊圈,轉動不磨殼體。
- 免螺絲前網罩:框體壓布網格後卡進前緣止口(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。
📦 相依: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 組裝說明書
- 列印零件:音箱主體、前網罩框、旋鈕×2、OLED 壓框 + 導光柱、背板(木紋 PETG)
- 腔體處理:內壁貼 5mm 止震棉(留倒相孔周圍)、倒相管熱熔膠固定
- 焊功放板:MAX98357A 三訊號線 + 5V/GND,喇叭線 22AWG 絞線
- 裝面板:編碼器鎖內支架、OLED 入壓框、導光柱對準板載 LED
- 入殼:主板螺絲固定、喇叭入前腔、線束理好不碰振膜
- 燒錄測試:PSRAM 選項確認 OPI、8 台全點一遍、斷 Wi-Fi 測自愈
- 交付儀式:教一次「轉一下有聲音、壓一下開關、卡住壓兩秒」——三句話,收工
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 完全無聲但 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 或手動開機 |
🎯 進階延伸
- 語音選台:S3 的 I2S1 接 INMP441 麥克風 + TinyML 關鍵字(「新聞」「古典」),喊一聲就切台——預留的第二組 I2S 就是為這個
- 播客卡帶:加 microSD 槽,把每週固定收聽的節目離線緩衝,玄關沒 Wi-Fi 也能聽
- 雙機立體聲:兩台 S3 用同一 NTP 時基同步播放,客廳變書房音響
- 長輩模式 2.0:加一顆實體「孫子頻道」按鈕——按下去直接播孫子傳的語音(Telegram Bot 收訊息播放)
📦 檔案清單
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 # 說明文件