📋 專案概述

練鼓第三年,你最大的敵人不是切分音,是晚上九點之後的一切。女兒剛滿兩歲,八點半就寢;大樓隔壁那對新手爸妈早就在社區群組點名過「8 樓的節奏鍵盤」。你把橡膠練習墊從地板挪到衣櫃裡再挪出來,循環了六次——因為它安靜是安靜,卻敲三個月就涼在角落:沒節奏反饋、沒數據、沒成就感,連「今天比昨天穩」都感覺不出來。

市售方案全數查過,每個都差臨門一腳:

本專案做一颗比滑鼠點擊還安靜、但真的有網皮回彈手感的鼓墊,內建壓電觸發與力度偵測——插電腦就是 MIDI 鼓,拔下來插行動電源就是節拍器+節拍燈+練習日記:

為什麼選 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 N16R81~230 元原生 USB+PSRAM
鼓墊面8" 網皮(維修片)1~160 元tom 用 6" 網皮 ~120 元(選配)
鈸8" 矽膠鈸1~250 元選配,先練 snare 也行
觸發27mm 陶瓷壓電圓片3~20 元舊報警器拆機亦可
螢幕SSD1306 0.96" I2C1~90 元BPM/拍號/校準向導
旋鈕編碼器(附按壓開關)1~25 元選 BPM/進校準
蜂鳴器被動式 5V1~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 DevKitC-1 N16R8 USB-C → PC(MIDI) GPIO1 ADC GPIO2 ADC GPIO3 ADC GPIO4 ADC(rim) GPIO5/6/7 編碼器 GPIO8/9 I2C GPIO10 蜂鳴 GPIO15 燈 snare 壓電前端 1MΩ+BAT54S→ADC tom 壓電前端 同上電路複製 鈸 壓電前端 同上電路複製 編碼器 A/B/SW OLED I2C 蜂鳴器←S8050 WS2812 ×8 ⚠ 壓電線全程纏屏蔽纏繞 GND;前端電路必須離板 10cm 內,高阻節點不怕長征
▲ 橘線=壓電前端→ADC1(GPIO1–4);四個 ADC 腳全部落在一號 ADC 上,避開 ADC2=Wi-Fi 共用的天坑。

詳細接線

ESP32-S3 腳位連接說明
GPIO1snare 壓電前端輸出ADC1_CH0;前端見下方電路
GPIO2 / GPIO3tom / 鈸 壓電前端ADC1,前端電路各一份
GPIO4rim 壓電(夾鼓框)ADC1;rimshot 用,可先懸空
GPIO5 / GPIO6 / GPIO7編碼器 A / B / SWA/B 內部上拉;SW 對 GND
GPIO8 / GPIO9OLED SDA / SCLI2C 0x3C
GPIO10蜂鳴器(經 S8050)1kΩ 基極;被動式,tone() 可變調
GPIO15(→330Ω)WS2812 DIN燈條端 1000µF
USB-CPC 或 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 列印零件

零件清單

零件數量材質建議顏色列印時間
鼓墊夾環(網皮勾鉤壓板)1PETG黑~40 min
壓電隔離船(甜甜圈墊法座)3PLA任意~12 min
控制盒斜面殼(螢幕+旋鈕)1PETG黑~55 min
編碼器旋鈕1PLA任意~10 min
減震腳(EPE 夾心外框)4TPU/PETG黑~25 min
rim 傳感壓夾1PETG黑~10 min

列印設定

設計要點

  1. 減音三層 Cake:網皮→鼓框(壓電隔離船在夾縫)→20mm EPE 泡棉床→桌面。低頻靠質量+泡棉吸收,實測穿桌傳到樓下的振動比放地板減少 70%
  2. 夾環共用鼓框螺絲孔:網皮維修片邊緣有勾鉤孔,列印夾環以 M4×30 八等分鎖在鼓框上——調網皮張力照常可用
  3. 壓電「甜甜圈」墊法:雙面網布膠帶貼成圓環、壓電片在正中央懸空 contacts——這是電子鼓大廠的公開結構秘密,比直接平黏靈敏 3 倍、串音少一半
  4. 控制盒 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 組裝說明書
  1. 列印零件:夾環 PETG、減震件 TPU/EPE;網皮用夾環鎖回鼓框,張力調到「敲下去回彈兩次不曖」
  2. 壓電甜甜圈墊法:雙面網布膠帶貼圓環,壓電片只黏中心 8mm、四周懸空——這是靈敏度的命脈
  3. 焊前端板×4:1MΩ/BAT54S/100nF;壓電線用屏蔽絞線,屏蔽接 GND 單端接地
  4. 燒錄+校準向導:長按旋鈕進校準,每區輕打 8 下、重打 8 下,閾值入 Flash
  5. USB MIDI 驗證:插 PC 開 MIDI 監控(或 GarageBand),敲 snare 應見 Note 38+velocity 隨力度變
  6. 節拍器試練:BPM 60 閉眼打四分音符 30 秒,看螢幕 dev——30ms 以內是正常成人
  7. NAS 統計端點:Flask 收 POST 寫 SQLite;週日彙報練鼓週報
  8. 整夜靜音驗證:凌晨用手機在門外錄音 1 分鐘,確認鼓聲融進冰箱底噪——過關即結案

🔧 問題排除

問題原因解決方法
沒敲也觸發(幽靈鼓點)壓電線懸空拾取雜訊/無放電電阻前端 100nF 焊在 ADC 腳邊、壓電線屏蔽單端接地、確認 1MΩ 有裝
力度永遠 127 滿檔峰值飽和(鉗位二極體沒焊=波形 clipping)重跑校準;仍滿檔則前端串 10k/20k 分壓把量程拉開
敲 snare 鈸也響結構傳導串音三墊各自隔離船(別共享底板);XTALK_PCT 40→60;減震腳加 EPE
連打丟單(ghost note 消失) LOCKOUT 太長或網皮張力過鬆回彈慢鎖緊網皮;練 blast beat 才改 LOCKOUT 120ms(需接受誤判率微升)
PC 收不到 MIDIUSB CDC On Boot 誤開Disabled 重燒;Windows 裝完裝置管理器看「軟體驱動程序:USB MIDI」
節拍燈比 click 聲慢半拍燈排在音之後更新本韌體已先燈後聲(2ms 差);自改順序的話會反過來難受

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32-C3 書房在座狀態燈 📚 系列索引 下一篇:ESP32-C3 迷你筆式繪圖機 →