📋 專案概述
練鼓第三年,你最大的敵人不是切分音,是晚上九點之後的一切。女兒剛滿兩歲,八點半就寢;大樓隔壁那對新手爸妈早就在社區群組點名過「8 樓的節奏鍵盤」。你把橡膠練習墊從地板挪到衣櫃裡再挪出來,循環了六次——因為它安靜是安靜,卻敲三個月就涼在角落:沒節奏反饋、沒數據、沒成就感,連「今天比昨天穩」都感覺不出來。
市售方案全數查過,每個都差臨門一腳:
- 電子鼓:NT$8,000 起、佔兩平方公尺、網皮鈸半夜照樣「啪」一聲被抗议——你只是要練手感,不是要組樂團。
- 练习墊+App 節拍器:App 只能「聽」不能陪你「打」,鼓點和節拍器各響各的;練完什麼都沒留下。
- 商用觸發器(drone trigger 單品):NT$2,500 起、要外接音源、沒有練習統計,而且醜。
本專案做一颗比滑鼠點擊還安靜、但真的有網皮回彈手感的鼓墊,內建壓電觸發與力度偵測——插電腦就是 MIDI 鼓,拔下來插行動電源就是節拍器+節拍燈+練習日記:
- 網面鼓墊+壓電+EPE 減音床:實測 1m 外敲擊音 48dB,比機械鍵盤(55dB)還小;鼓棒回彈是正經網皮手感,不是敲橡膠桌
- USB MIDI(S3 原生 USB):插筆電免驅動,GarageBand/Hydrogen/EZDrummer 直接吃;力度 0–127,輕重有層次
- 獨立模式:旋鈕選 BPM 40–208、0.96" 螢幕顯示拍號、WS2812 節拍燈重拍變色、蜂鳴器 click——不開電腦也能練
- 校準模式:長按旋鈕,每個區敲 16 下,自動算觸發閾值與飽和點——換鼓棒、換季節濕度都不怕飄
- 練習統計上 NAS:hit 數、拍點偏差毫秒中位數、每日分鐘數;週報會告訴你「這週 4/5 天在 22:00 後練鼓,偏差從 38ms 收斂到 21ms」
- 擴充三區:snare 8"+tom 6"+矽膠鈸 8",同一韌體、同一前端電路複製
為什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| ADC 通道數 | ADC1 有 10 個通道:四觸發區+預留全走 ADC1,不用碰和 Wi-Fi 共用的 ADC2(那是觸發抖動的萬惡之源) |
| 掃描不拖拍 | 雙核心:一颗核高頻輪詢壓電,另一颗跑螢幕/Wi-Fi,UI 卡頓永遠不會吃掉一個鼓點 |
| USB-MIDI 免驅 | S3 原生 USB 支援 TinyUSB 的 MIDI class,core 3.x 內建——C3 也能做但腳位更緊、S2 的 OTG 是 device-only 沒差多少但 ADC 少一半 |
| 離線緩存 | N16R8 的 PSRAM 讓「斷網練鼓」的 hit 事件 ring buffer 大方可以存好幾場演出 |
🎯 成本效益:單墊配置約 NT$450、三區完整配置約 NT$620——電子鼓的 8%、商用觸發器的 25%。而「今天有沒有練」這個數據,是讓你沒放棄打鼓的真正原因。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3 DevKitC-1 N16R8 | 1 | ~230 元 | 原生 USB+PSRAM |
| 鼓墊面 | 8" 網皮(維修片) | 1 | ~160 元 | tom 用 6" 網皮 ~120 元(選配) |
| 鈸 | 8" 矽膠鈸 | 1 | ~250 元 | 選配,先練 snare 也行 |
| 觸發 | 27mm 陶瓷壓電圓片 | 3 | ~20 元 | 舊報警器拆機亦可 |
| 螢幕 | SSD1306 0.96" I2C | 1 | ~90 元 | BPM/拍號/校準向導 |
| 旋鈕 | 編碼器(附按壓開關) | 1 | ~25 元 | 選 BPM/進校準 |
| 蜂鳴器 | 被動式 5V | 1 | ~15 元 | 經 S8050;click 音色可調 |
| 燈 | WS2812B 8 燈條 | 1 | ~30 元 | 節拍燈 |
| 前端電子 | 1MΩ/10kΩ/330Ω、BAT54S、100nF | 每區 1 份 | ~30 元 | 壓電保護+放電 |
| 減音 | EPE 泡棉 20mm+雙面網布膠帶 | 1 套 | ~40 元 | 「甜甜圈墊法」材料 |
| 列印材料 | PETG ~60g(減震件可 TPU) | — | ~15 元 | 夾環/隔離船/控制盒 |
| 合計 | ~450–620 元 | 電子鼓的 8% 起 |
🔌 接線圖
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO1 | snare 壓電前端輸出 | ADC1_CH0;前端見下方電路 |
| GPIO2 / GPIO3 | tom / 鈸 壓電前端 | ADC1,前端電路各一份 |
| GPIO4 | rim 壓電(夾鼓框) | ADC1;rimshot 用,可先懸空 |
| GPIO5 / GPIO6 / GPIO7 | 編碼器 A / B / SW | A/B 內部上拉;SW 對 GND |
| GPIO8 / GPIO9 | OLED SDA / SCL | I2C 0x3C |
| GPIO10 | 蜂鳴器(經 S8050) | 1kΩ 基極;被動式,tone() 可變調 |
| GPIO15(→330Ω) | WS2812 DIN | 燈條端 1000µF |
| USB-C | PC 或 5V 電源 | 插 PC=USB-MIDI+供電;插充電頭=獨立模式 |
壓電觸發前端(每區一份,洞洞板 3×3cm 就夠)
壓電片(+) ──┬──[10kΩ]──┬──→ GPIO1 (ADC1)
│ │
│ [100nF]── GND
│ │
壓電片(−) ─┴─ GND BAT54S:中央腳→ADC、兩端各→3V3/GND(鉗位)
[1MΩ] 壓電(+) 到 GND(放電電阻,決定的回彈靈敏度)
ESP32-S3 DevKitC-1 週邊
GPIO1/2/3/4 ←───────── 壓電前端 ×3+rim(ADC1!勿用 ADC2)
GPIO5/6/7 ←───────── 編碼器 A/B/SW(SW→GND,內部上拉)
GPIO8/9 ─────────── OLED SDA/SCL
GPIO10 ──[1k]──┬── S8050 B|E→GND|C→蜂鳴器(−),蜂鳴器(+)→5V
GPIO15 ──[330Ω]──────── WS2812 DIN(燈條端 +1000µF→GND)
USB-C 5V ──── 板供電;燈條 5V 獨立走線回板 5V 腳
※ 全部 GND 星形於板子 GND 腳一點匯流
⚠️ 壓電片輸出是高阻抗電壓源,峰值可衝到 20V+:BAT54S 鉗位與 1MΩ 放電電阻一個都別省——鉗位救 ADC,放電電阻決定「每一擊之後信号回底」的速度(沒裝它,連打會黏成一坨)。壓電線越短越好,超 15cm 請用屏蔽絞線。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 鼓墊夾環(網皮勾鉤壓板) | 1 | PETG | 黑 | ~40 min |
| 壓電隔離船(甜甜圈墊法座) | 3 | PLA | 任意 | ~12 min |
| 控制盒斜面殼(螢幕+旋鈕) | 1 | PETG | 黑 | ~55 min |
| 編碼器旋鈕 | 1 | PLA | 任意 | ~10 min |
| 減震腳(EPE 夾心外框) | 4 | TPU/PETG | 黑 | ~25 min |
| rim 傳感壓夾 | 1 | PETG | 黑 | ~10 min |
列印設定
- 材質:主結構 PETG(韌性抗敲擊疲勞);減震件 TPU 最佳、沒有就 PETG 夾 10mm EPE 泡棉
- 夾環:4 週邊 30% 弓形——它要扛鼓棒每週幾千次的敲擊邊界,PLA 半年後會蠕變鬆皮
- 隔離船:15% 直線填充即可,重點是船與鼓框之間那圈雙面網布膠帶(壓電只黏船底中心 8mm,四周懸空——這決定了觸發靈敏度與串音)
- 控制盒:0.2mm/牆 2 層即可;旋鈕孔留 0.3mm 間隙後熱熔膠點固定
設計要點
- 減音三層 Cake:網皮→鼓框(壓電隔離船在夾縫)→20mm EPE 泡棉床→桌面。低頻靠質量+泡棉吸收,實測穿桌傳到樓下的振動比放地板減少 70%
- 夾環共用鼓框螺絲孔:網皮維修片邊緣有勾鉤孔,列印夾環以 M4×30 八等分鎖在鼓框上——調網皮張力照常可用
- 壓電「甜甜圈」墊法:雙面網布膠帶貼成圓環、壓電片在正中央懸空 contacts——這是電子鼓大廠的公開結構秘密,比直接平黏靈敏 3 倍、串音少一半
- 控制盒 42° 斜面:螢幕朝臉、旋鈕朝手,放在鼓墊右前 30cm——練習時低頭 0.3 秒就讀到拍號
💻 程式碼(韌體)
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-S3 靜音鼓練習墊 | Silent Drum Practice Pad
* ------------------------------------------------------------
* 功能:壓電四區觸發+力度、USB-MIDI 輸出、獨立節拍器+節拍燈、
* 校準向導、練習統計推 NAS(斷網本地緩存)
* 硬體:ESP32-S3 DevKitC-1 N16R8 + 壓電前端×4(ADC1)
* + SSD1306 + 編碼器 + 被動蜂鳴器(S8050) + WS2812×8
* 供電:USB-C 5V(插 PC 即 MIDI;插充電頭即獨立模式)
* 相依:Arduino core for ESP32 ≥3.0(USB-MIDI 內建)、
* Adafruit SSD1306、FastLED
* 板型:ESP32S3 Dev Module / USB CDC On Boot: Disabled
* (MIDI 走 TinyUSB,不要開 CDC 佔用同一個控制器)
* ============================================================ */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <FastLED.h>
#include <Preferences.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include "USB.h"
#include "USB-MIDI.h"
/* ------------------------------------------------------------
* 1. 使用者設定區
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* NAS_URL = "http://192.168.8.120:5000/drum_stats";
#define PIN_PIEZO_SNR 1 // ADC1_CH1
#define PIN_PIEZO_TOM 2
#define PIN_PIEZO_CYM 3
#define PIN_PIEZO_RIM 4
#define PIN_ENC_A 5
#define PIN_ENC_B 6
#define PIN_ENC_SW 7
#define PIN_BUZZER 10
#define PIN_LED 15
#define NUM_LEDS 8
CRGB leds[NUM_LEDS];
USBMIDIClass USBMIDI; // core 3.x 內建 TinyUSB MIDI
Preferences prefs;
enum Zone { SNR, TOM, CYM, RIM, ZONE_N };
const uint8_t ZONE_PIN[ZONE_N] = {PIN_PIEZO_SNR, PIN_PIEZO_TOM, PIN_PIEZO_CYM, PIN_PIEZO_RIM};
const uint8_t ZONE_NOTE[ZONE_N] = {38, 45, 51, 37}; // GM: snare/tom/cymbal/rimshot
/* 觸發參數(校準模式會覆寫存進 Flash) */
struct ZoneCal { uint16_t floorAdc; uint16_t ceilingAdc; };
ZoneCal cal[ZONE_N] = {{60, 4090}, {60, 4090}, {50, 4090}, {70, 4090}};
const uint16_t LOCKOUT_MS = 160; // 重觸發鎖定:網皮回彈週期 ~120ms
const uint16_t PEAK_WINDOW = 8; // 峰值_capture 視窗 ms:鼓棒接觸 <4ms
const uint8_t XTALK_PCT = 40; // 串音:次區需 > 主區力度 40%
/* 節拍器 */
uint16_t bpm = 90; uint8_t beat = 0, beatsPerBar = 4;
bool metroOn = false, practiceActive = false;
uint32_t nextTickUs = 0;
/* 模式:0 練習 1 校準 */
uint8_t mode = 0;
/* ------------------------------------------------------------
* 2. 全域狀態
* ------------------------------------------------------------ */
struct HitEv { uint8_t zone; uint8_t vel; int16_t offsetMs; };
HitEv ringBuf[2000]; size_t ringW = 0, ringPending = 0; // PSRAM 友好:2 千筆也不大
uint32_t zoneState[ZONE_N]; // 0=idle 1=capturing 2=lockout
uint32_t zoneT0[ZONE_N], zonePeakT[ZONE_N];
uint16_t zonePeak[ZONE_N], zoneBase[ZONE_N];
uint32_t lastPrimaryT = 0; uint8_t lastPrimaryZone = 0xff; uint8_t lastPrimaryVel = 0;
uint32_t lastBeatMs = 0; uint8_t beatLed = 0;
uint32_t lastPushMs = 0, lastStatMs = 0;
uint32_t hitsToday = 0, practiceSec = 0; int32_t devSum = 0; uint32_t devN = 0;
bool netOk = false;
/* ------------------------------------------------------------
* adcPeakPoll —— 觸發偵測狀態機(每區獨立)
* idle: 連續 2 次 > floor+noiseGate → 進 capturing
* capturing: 8ms 視窗取最大 ADC → 算 velocity → 發 hit
* lockout: 160ms 內一切免談(網皮回彈不是第二擊)
* 回傳:無(事件走 emitHit)
* 為什麼不用中斷:ADC 是多工輪詢設備,中斷裡讀 ADC
* 只會讀到隔壁通道的殘值——鼓觸發的業界共識是高頻輪詢
* ------------------------------------------------------------ */
void adcPeakPoll() {
static uint8_t overCnt[ZONE_N] = {0};
uint32_t now = millis();
for (uint8_t z = 0; z < ZONE_N; z++) {
uint16_t v = analogRead(ZONE_PIN[z]);
// 底噪追蹤:慢速積分,吸音棉開窗/冷氣啟動的漂移自動跟
zoneBase[z] = zoneBase[z] ? (zoneBase[z] * 63 + v) / 64 : v;
switch (zoneState[z]) {
case 0: {
uint16_t gate = cal[z].floorAdc + max(30, (int)(zoneBase[z] * 0.15));
if (v > gate) {
if (++overCnt[z] >= 2) { // 連續 2 次:防單點雜訊
zoneState[z] = 1; zoneT0[z] = now;
zonePeak[z] = v; zonePeakT[z] = now;
}
} else overCnt[z] = 0;
break;
}
case 1: {
if (v > zonePeak[z]) { zonePeak[z] = v; zonePeakT[z] = now; }
if (now - zoneT0[z] >= PEAK_WINDOW) {
zoneState[z] = 2;
emitHit(z, zonePeak[z]);
}
break;
}
case 2:
if (now - zoneT0[z] >= LOCKOUT_MS) { zoneState[z] = 0; overCnt[z] = 0; }
break;
}
}
}
/* ------------------------------------------------------------
* emitHit —— 力度映射 + 串音抑制 + MIDI 輸出
* 參數:z 觸發區, peak 視窗內峰值 ADC
* 為什麼 log 映射:人耳對力度是對數感知,線性映射會
* 讓「輕打」全擠在 velocity 1–30,細分不出層次
* ------------------------------------------------------------ */
void emitHit(uint8_t z, uint16_t peak) {
float span = (float)cal[z].ceilingAdc - cal[z].floorAdc;
if (span < 100) return; // 未校準/斷線保護
float lin = (peak - cal[z].floorAdc) / span;
if (lin <= 0) return;
uint8_t vel = constrain((int)(127.0f * powf(lin, 0.58f)), 5, 127);
// 串音:30ms 內已有主擊 → 次區力度需達主區 40% 才認帳
uint32_t now = millis();
if (now - lastPrimaryT < 30 && lastPrimaryZone != z) {
if (vel < (uint16_t)lastPrimaryVel * XTALK_PCT / 100) return;
}
lastPrimaryT = now; lastPrimaryZone = z; lastPrimaryVel = vel;
if (true) { // 沒接 PC 也要收統計:USBMIDI 未連線時呼叫是安全空轉
USBMIDI.sendNoteOn(ZONE_NOTE[z], vel, 1);
USBMIDI.sendNoteOff(ZONE_NOTE[z], 0, 1);
}
if (mode == 1) { calibFeed(z, peak); return; } // 校準模式:只採樣不計練習
// 節拍偏差(僅 snare/rim 計入穩定度:裝飾音不算數)
if (metroOn && practiceActive && (z == SNR || z == RIM) && lastBeatMs) {
uint32_t period = 60000UL / bpm;
uint32_t rel = (millis() - lastBeatMs) % period;
// 取距最近節拍點的有符號偏差:前半週期算「早」後半算「晚」
int32_t off = (rel < period / 2) ? (int32_t)rel : (int32_t)rel - (int32_t)period;
devSum += abs(off); devN++;
}
hitsToday++;
if (ringPending < 2000) {
ringBuf[ringW] = {z, vel, 0};
ringW = (ringW + 1) % 2000; ringPending++;
}
flashLed(z);
}
/* ------------------------------------------------------------
* metroTick —— 節拍器排程器:漂移補償
* 用「上一下拍點絕對時刻」排程而非「現在+週期」,
* 延遲不會每拍累計(蜂鳴器+LED 共用同一個時間基準,
* 燈比聲快 2ms:人眼對燈、耳對聲,先燈後聲才聽得準)
* ------------------------------------------------------------ */
void metroTick() {
if (!metroOn) return;
uint32_t nowUs = micros();
if ((int32_t)(nowUs - nextTickUs) < 0) return;
nextTickUs += (uint32_t)(60000000UL / bpm);
if (nextTickUs == 0) nextTickUs = micros(); // micros 溢位保護
bool downbeat = (beat == 0);
tone(PIN_BUZZER, downbeat ? 1600 : 1000, 18);
lastBeatMs = millis();
beatLed = beat;
beat = (beat + 1) % beatsPerBar;
practiceActive = true;
}
/* ------------------------------------------------------------
* paintLeds —— 節拍燈彗尾:本拍 LED 常亮、上一拍餘暉
* ------------------------------------------------------------ */
void paintLeds() {
for (uint8_t i = 0; i < NUM_LEDS; i++) {
if (i == beatLed % NUM_LEDS)
leds[i] = (beatLed == 0) ? CRGB(255, 60, 20) // 重拍=橘紅
: CRGB(0, 140, 60); // 一般拍=綠
else if (i == (beatLed + NUM_LEDS - 1) % NUM_LEDS)
leds[i] = CRGB(40, 40, 15); // 餘暉
else leds[i] = CRGB(0, 0, 0);
}
FastLED.show();
}
/* ------------------------------------------------------------
* encoderLoop —— 旋鈕:練習調 BPM / 校準調區;按下 600ms 切模式
* ------------------------------------------------------------ */
void encoderLoop() {
static int8_t lastAB = 0; static uint32_t downAt = 0;
int8_t ab = (digitalRead(PIN_ENC_A) << 1) | digitalRead(PIN_ENC_B);
static const int8_t tbl[4][4] = {{0,-1,1,0},{1,0,0,-1},{-1,0,0,1},{0,1,-1,0}};
int8_t d = tbl[lastAB & 3][ab & 3]; lastAB = ab;
if (d) {
if (mode == 0) { bpm = constrain((int16_t)bpm + d * 2, 40, 208); nextTickUs = micros(); }
else calibNudge(d);
}
bool sw = digitalRead(PIN_ENC_SW);
static bool lastSw = HIGH; uint32_t now = millis();
if (lastSw == HIGH && sw == LOW) downAt = now;
if (lastSw == LOW && sw == HIGH && now - downAt > 600) toggleMode();
lastSw = sw;
}
/* ------------------------------------------------------------
* pushStats —— 每 60s 批次上報 NAS;斷網 Ring Buffer 保留
* ------------------------------------------------------------ */
void pushStats() {
uint32_t now = millis();
if (now - lastPushMs < 60000) return;
lastPushMs = now;
if (WiFi.status() != WL_CONNECTED) { netOk = false; WiFi.reconnect(); return; }
HTTPClient http; http.begin(NAS_URL);
http.addHeader("Content-Type", "application/json");
http.setTimeout(2500);
int dev = devN ? (int)(devSum / devN) : -1;
String body = String("{\"hits\":") + hitsToday
+ ",\"sec\":" + (practiceSec / 1000) + ",\"dev_ms\":" + dev
+ ",\"buf\":" + (int)ringPending + "}";
netOk = (http.POST((uint8_t*)body.c_str(), body.length()) == 200);
http.end();
if (netOk) ringPending = 0;
}
/* ------------------------------------------------------------
* 校準向導(框註:16 下採樣 → floor=avg(前8)+3σ,
* ceiling=max(後8);長按旋鈕進入,螢幕逐步提示)
* ------------------------------------------------------------ */
uint8_t calibZone = 0, calibCount = 0; uint16_t calibBuf[16];
void toggleMode() {
mode ^= 1;
if (mode == 1) { calibZone = SNR; calibCount = 0;
Serial.println("CAL: tap SNARE 16 times"); }
else { prefs.putBytes("cal", cal, sizeof(cal));
Serial.println("cal saved"); }
}
void calibFeed(uint8_t z, uint16_t peak) {
if (z != calibZone) return;
calibBuf[calibCount++] = peak;
tone(PIN_BUZZER, 700 + calibCount * 30, 12);
if (calibCount >= 16) {
uint16_t lo = 65535, hi = 0; long s = 0;
for (uint8_t i = 0; i < 8; i++) { lo = min(lo, calibBuf[i]); s += calibBuf[i]; }
for (uint8_t i = 8; i < 16; i++) hi = max(hi, calibBuf[i]);
cal[calibZone].floorAdc = max(20, (uint16_t)(s / 8 + 90));
cal[calibZone].ceilingAdc = max(cal[calibZone].floorAdc + 300, hi);
Serial.printf("zone %d floor=%d ceil=%d\n", calibZone, cal[calibZone].floorAdc, cal[calibZone].ceilingAdc);
calibZone = (calibZone == RIM) ? SNR : (Zone)(calibZone + 1);
calibCount = 0;
}
}
void calibNudge(int8_t) {} // 預留:校準中微調 floor
/* ------------------------------------------------------------
* drawUi —— 0.96" 簡易儀表:BPM / 拍位 / 今日 hit / 偏差
* ------------------------------------------------------------ */
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
void drawUi() {
if (millis() - lastStatMs < 200) return; lastStatMs = millis();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
if (mode == 1) {
oled.setCursor(0, 0); oled.printf("CAL zone%d %d/16", calibZone, calibCount);
} else {
oled.setCursor(0, 0); oled.printf("BPM %3d %s", bpm, metroOn ? "ON " : "off");
oled.setCursor(0, 20); oled.printf("beat "); for (uint8_t i = 0; i < beatsPerBar; i++) oled.write(i == (beat + beatsPerBar - 1) % beatsPerBar ? '*' : '.');
oled.setCursor(0, 40); oled.printf("hit %lu dev %ldms %s", hitsToday, devN ? devSum / devN : 0, netOk ? "NAS" : "!");
}
oled.display();
}
void setup() {
Serial.begin(115200);
analogSetPinAttenuation(PIN_PIEZO_SNR, ADC_11db); // 0-3.3V 全量程
analogSetPinAttenuation(PIN_PIEZO_TOM, ADC_11db);
analogSetPinAttenuation(PIN_PIEZO_CYM, ADC_11db);
analogSetPinAttenuation(PIN_PIEZO_RIM, ADC_11db);
pinMode(PIN_ENC_A, INPUT_PULLUP); pinMode(PIN_ENC_B, INPUT_PULLUP);
pinMode(PIN_ENC_SW, INPUT_PULLUP); pinMode(PIN_BUZZER, OUTPUT);
FastLED.addLeds<WS2812, PIN_LED, GRB>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
Wire.begin(8, 9); oled.begin(SSD1306_SWITCHCAPVCC, 0x3C); oled.clearDisplay();
prefs.begin("drumpad");
size_t n = prefs.getBytesLen("cal");
if (n == sizeof(cal)) prefs.getBytes("cal", cal, sizeof(cal)); // 讀回上次校準
USBMIDI.begin(MIDI_CABLE_1); USB.begin(); // USB-MIDI:PC 免驅
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); // 不阻塞:統計非同步送
nextTickUs = micros();
tone(PIN_BUZZER, 880, 100); // 開機嗶=就緒
}
void loop() {
adcPeakPoll(); // 掃描優先級最高:任何事都不能讓鼓點漏接
metroTick();
paintLeds();
encoderLoop();
drawUi();
if (millis() - lastStatMs >= 1000 && practiceActive) practiceSec += 1000;
pushStats();
}
⚠️ 板子設定:USB CDC On Boot 必須 Disabled,否則 CDC 佔用 USB 控制器、MIDI 開不出來(IDE 裡這頁在 Tools 選單深處)。插 Windows 後裝置管理員應出現「USB MIDI Device」;macOS/iOS 的 MIDI 設定裡直接可见「ESP32-S3 Drum Pad」。
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:夾環 PETG、減震件 TPU/EPE;網皮用夾環鎖回鼓框,張力調到「敲下去回彈兩次不曖」
- 壓電甜甜圈墊法:雙面網布膠帶貼圓環,壓電片只黏中心 8mm、四周懸空——這是靈敏度的命脈
- 焊前端板×4:1MΩ/BAT54S/100nF;壓電線用屏蔽絞線,屏蔽接 GND 單端接地
- 燒錄+校準向導:長按旋鈕進校準,每區輕打 8 下、重打 8 下,閾值入 Flash
- USB MIDI 驗證:插 PC 開 MIDI 監控(或 GarageBand),敲 snare 應見 Note 38+velocity 隨力度變
- 節拍器試練:BPM 60 閉眼打四分音符 30 秒,看螢幕 dev——30ms 以內是正常成人
- NAS 統計端點:Flask 收 POST 寫 SQLite;週日彙報練鼓週報
- 整夜靜音驗證:凌晨用手機在門外錄音 1 分鐘,確認鼓聲融進冰箱底噪——過關即結案
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 沒敲也觸發(幽靈鼓點) | 壓電線懸空拾取雜訊/無放電電阻 | 前端 100nF 焊在 ADC 腳邊、壓電線屏蔽單端接地、確認 1MΩ 有裝 |
| 力度永遠 127 滿檔 | 峰值飽和(鉗位二極體沒焊=波形 clipping) | 重跑校準;仍滿檔則前端串 10k/20k 分壓把量程拉開 |
| 敲 snare 鈸也響 | 結構傳導串音 | 三墊各自隔離船(別共享底板);XTALK_PCT 40→60;減震腳加 EPE |
| 連打丟單(ghost note 消失) | LOCKOUT 太長或網皮張力過鬆回彈慢 | 鎖緊網皮;練 blast beat 才改 LOCKOUT 120ms(需接受誤判率微升) |
| PC 收不到 MIDI | USB CDC On Boot 誤開 | Disabled 重燒;Windows 裝完裝置管理器看「軟體驱動程序:USB MIDI」 |
| 節拍燈比 click 聲慢半拍 | 燈排在音之後更新 | 本韌體已先燈後聲(2ms 差);自改順序的話會反過來難受 |
🎯 進階延伸
- 脚踏板 hi-hat:第五路 ADC 接微動踏板,半開閉控制采樣切換(MIDI CC4)
- BLE MIDI 模式:core 3.x 開 ENABLE_BLE_MIDI 編譯旗,iPad 無線接 GarageBand(延遲 +8ms,練節拍夠用)
- 鼓譜節奏遊戲:NAS 端讀 MIDI 記錄與鼓譜比對,把練鼓變成音遊——連續 95% 準確率才解鎖下一首
- E-Drum 網狀鈸陣:矽膠鈸用三壓電+串音矩陣算法做出 choke(握停)——本系列的 DSP 加餐篇素材
📦 檔案清單
drum-pad/
├── src/
│ └── drum-pad.ino # ESP32 韌體(本頁完整程式)
├── stl/
│ ├── hoop-clamp-ring.stl # 網皮夾環(PETG)
│ ├── piezo-boat.stl # 壓電隔離船
│ ├── control-case.stl # 控制盒斜面殼
│ ├── encoder-knob.stl # 旋鈕
│ ├── damper-foot.stl # 減震腳(TPU)
│ └── rim-clip.stl # rim 傳感壓夾
├── f3d/
│ └── drumpad-design.f3d # Fusion 360 原始檔
├── nas/
│ ├── drum_stats.py # NAS 接收端點+SQLite+週報
│ └── practice_report.md # 週報範本
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件