📋 專案概述

兒子學琴第三個月,開始每週固定出現一次「手指痛」。我沒買第二台電鋼的預算,卻一直想讓他玩一樣不需要精準按鍵的東西——上週陪他看《生活大爆炸》,Sheldon 兩只手懸在空氣裡「彈」出一段旋律,他在沙發上整個人跳起來:「這個不用按!」那一幕就是特雷門(Theremin),1920 年代發明的第一支電子樂器,也是到今天看起來還最像魔法的一支。

於是我認認真真查了一晚上貨架:

  1. 正经特雷門(MOEEUS THEREMIN V2 那一類):台幣 3,900 元起。買一台「可能彈兩次就收進櫃子」的樂器給一個剛說手指痛的八歲小孩?這筆帳算不通。
  2. 淘寶玩具特雷門:900 元。評價區全是「音準像喝醉」「兩根天線互相打架」「調完 A 音 B 音跟著跑」——它的本體是兩顆 555 振盪器外差,漂移是天性,不是品控問題。
  3. DIY 古法(555/CD4011 雙振盪差頻):材料 300 元,但要拿示波器把兩顆振盪器調到彼此不_pull-in_、再跟溫度掙扎——那不是做樂器,是拜師。

然後我看見 ESP32 家族裡一個長年被冷落型號的規格表:ESP32-S2 內建最多 14 通道電容式觸覺感應(Touch Sensor),而且內建兩顆 8-bit DAC。「手掌隔空改變電容」正是特雷門天線物理學的一模一樣那條公式,「DAC 直出音頻」正是揚聲器要的東西——一支 110 元的板子把這台的兩條腿都買齊了。於是有這篇:

帳目:MOEEUS 3,900 元、玩具款 900 元(評價看下來等同丟 900 元)、本機 455 元。它不會让孩子从此爱上练琴——它做的是把「原來我手一挥就能创造音乐」这件事,变成一个 455 元、当晚就能实现的晚上。

為什麼選 ESP32-S2?

需求S2 的優勢(以及其他型號為什麼不行)
隔空測電容S2 內建 14 通道觸覺感應,touch1/touch2 固定在 GPIO1/GPIO2,天生就是為「掌心懸停測距」設計的。C3/C6/C2/H2 全線沒有觸控通道;初代 WROOM-32 有 touch0–9 但早在 ESP32 上就已邊緣化,S2 是這條產物的完全體
音頻輸出S2 有兩顆 8-bit DAC(GPIO17/18),正弦波直接寫 register 等級輸出。C 系列全線無 DAC——PWM 硬湊要外加高阶濾波,止損在 100Hz 以下,樂器直接廢一半;S3 也有 DAC 但多花 90 元买双核+PSRAM,本專案一行都用不上
即時性音頻任务要穩定 44.1kHz 不抖。S2 單核 Xtensa LX7 @240MHz,把音頻 task 掛最高優先權、控制 task 掛一般優先權,時脈夠、中斷模型單純,沒有雙核 cache 一致性的抖動變數
它是一台「樂器」不是節點本機零網路:不開 Wi-Fi、不连云,插電即奏。S2 沒有 BLE 在這裡反而是優點——規格里就幫你排除了「手癢想幫它上 APP」的誘惑,待機功耗曲線乾淨,插著行動電源就能拿到客廳開演奏會

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-S2-DevKitM-1(S2FN4R2)1~110 元必须是 S2 FN4R2(含 DAC);標牌寫 S2Mini 的便宜板多半是 SA 版本而 SA 也有 DAC,但 flash 2MB,不影響本專案
功放PAM8403 3W 模組1~15 元D 类、兩顆運放都不用買;音量旋鈕留在板邊方便現場調
喇叭2 吋 4Ω 2W 全頻1~25 元4Ω 對接 PAM8403 輸出最討好;別用 8Ω 3W 網拍「智能音箱拆機喇叭」,音量小一截
天線芯10mm 直徑鋁棒(或銅棒)2~10 元/根各 9cm,號角頂部穿心而出;鋁棒比銅棒輕,塔身不倒
迴路導線0.5mm 漆包線2m~8 元在天線根部繞三圈閉合迴路——真正感應的是這個迴路+手構成的電容,不是棒子本身
指示燈條WS2812B 燈條(8 顆)1~35 元DIN 前串 330Ω;供電端加 1000µF 緩衝 capacitance
濾波料1kΩ×2、100nF C0G×2、10µF×2、330Ω×11 組~8 元兩階 RC 低通(fc≈1.6kHz)+ DAC 直流偏置隔直
校準按鈕6×6 輕觸開關1~5 元接 GPIO0(非 strapping、開機別按住即可),內部上拉免外電阻
列印件底座+左/右號角(PLA 約 70g)1 組~145 元詳見 3D 列印節;不含列印買现成木座更便宜但少了點氣勢
電源與雜項5V 1A USB 火牛、USB-C 線、矽膠腳墊1 組~24 元火牛請用「品質好的」——D 類功放對電源雜訊比想像中誠實
合計~455 元

🔌 接線圖

ESP32-S2 DevKitM-1 USB 5V 供電 GPIO1 GPIO2 GPIO0 GND GPIO17 GPIO8 3V3 右號角:音高迴路 鋁棒 9cm+漆包線三圈 TOUCH1 左號角:音量迴路(虛線=第二迴路) TOUCH2 校準按鈕→GND 1kΩ 1kΩ 100nF×2 fc≈1.6kHz PAM8403 IN+ ← RC 輸出 2W 喇叭 WS2812 音高譜(C→B 按音名上色) 3V3(燈條 VIA 5V) GND 共地:燈條/功放/板三點單點相連
圖一:ESP32-S2 特雷門全機接線。紅色=觸控/信號、綠=音頻、橘=電源、深藍=GND
ASCII 對照表(DevKitM-1 左排 / 右排) ┌─ ESP32-S2-DevKitM-1 ─────────────┐ │ RST 3V3 ●────────→(燈條 5V 經 330Ω 訊號側供電,或獨立 5V) │ GND ●──┬──────→ 功放 GND / 燈條 GND(單點共地) │ GND ●─────┘ │ │ 3V3 ● │ │ GPIO0 ●──→ 校準按鈕(接 GND,內部上拉) │ GPIO1 ●──→ 右號角 TOUCH1(音高迴路,漆包線閉合迴路) │ GPIO2 ●──→ 左號角 TOUCH2(音量迴路) │ GPIO8 ●──→ WS2812 DIN(串 330Ω) │ GPIO17●──→ 1kΩ→100nF→1kΩ→100nF→PAM8403 IN+(DAC0,唯一正確答案) │ 5V ●──→ 火牛 USB 5V └──────────────────────────────────┘ ⚠ touch1/touch2 固定是 GPIO1/GPIO2(晶片硬性規定),程式裡改不動。

詳細接線

ESP32-S2 腳位連接說明
GPIO1(TOUCH1)右號角迴路(漆包線內側芯線)音高迴路;迴路另一端懸空即可,觸控通道量的是「導體對地電容」
GPIO2(TOUCH2)左號角迴路音量迴路;與音高迴路中心距 ≥12cm,避免兩手互抓
GPIO17(DAC_1)兩階 RC 低通 → PAM8403 IN+S2 的 DAC 輸出 0–255 對應 0–3.3V,中點 128 為 silence 偏置
GPIO8WS2812 DIN(330Ω)避開 GPIO19/20(USB D±),SuperMini 改版同腳位可用
GPIO0校準按鈕 → GNDS2 的 GPIO0 非 strapping 腳,按住開機不會變砖
5V / GNDPAM8403 供電、燈條供電燈條 8 顆全亮峰值約 160mA,加喇叭 2W,火牛要老實 1A

🧊 3D 列印設計

① 右號角(音高)+迴路 ② 左號角(音量) ③ 鋁棒 Ø10×90mm ×2(穿心) ④ S2 板+PAM8403 堆疊 ⑤ WS2812 8 燈+導光壓條 ⑥ 底座(15° 上翹、配重槽)
圖二:六個列印件+兩根天線的爆炸關係。號角以 M3 螺絲鎖進底座插梢孔,天線穿心後點熱熔膠固定
零件材質列印設定時間備註
底座(15° 上翹)PLA0.2mm/20% 菱形填充/底部實心 4 層~100 分預留 60g 配重槽(螺母或備用 18650),不然兩手一靠整台前翻
右號角錐PLA0.2mm/樹狀支持/頂孔 Ø10.2mm~80 分內壁預留漆包線走線槽(寬 1mm 深 1mm)
左號角錐PLA同右號角(互為鏡像)~80 分兩迴路中心距 19cm(實測串音最小值)
燈條導光壓條透明 PETG0.2mm/實心~25 分透明 PLA 易黃,PETG 透光久一點
板托+功放夾PLA0.28mm/15% 填充~45 分板子 M2.5 螺絲鎖死,USB-C 口開天窗方便換韌體

💻 Arduino 程式碼

板管理器選「esp32 by Espressif Systems」≥ 2.0.9,開發板選 ESP32S2 Dev Module,USB CDC On Boot: Enabled。只需要一顆相依:Adafruit NeoPixel。

📐 註解規範:區塊以框註分段、函式附用途/參數/回傳框註、行內註解解釋「為什麼」。全系列同一規範。
💡 本機完全離線:整台不連 Wi-Fi,开机即奏——乐器的开机时间应该是一眨眼,不是「等待網路中」。
/* ================================================================
 *  ESP32-S2 免接觸特雷門  firmware v1.0  (2026-10-24)
 *  ----------------------------------------------------------------
 *  功能   :雙觸控迴路特雷門——右手音高 / 左手音量,
 *           44.1kHz 正弦波表合成(8-bit DAC 直出),
 *           WS2812 八燈音高譜,一鍵校準存 NVS
 *  硬體   :ESP32-S2-DevKitM-1
 *           TOUCH1=GPIO1 右號角(音高)  TOUCH2=GPIO2 左號角(音量)
 *           DAC0=GPIO17 → 兩階 RC → PAM8403 → 2W 喇叭
 *           WS2812 DIN=GPIO8(330Ω)   校準按鈕=GPIO0(對 GND)
 *  供電   :USB 5V 1A(燈條+功放建議獨立 5V 或粗線)
 *  相依   :Adafruit NeoPixel;core ≥ 2.0.9
 *  ================================================================ */
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <esp_timer.h>
#include <driver/dac.h>

/* --- 1. 腳位與原則性常數 -------------------------------------- */
#define TOUCH_PITCH   1        // GPIO1:touch1 是晶片硬性綁定,不能改
#define TOUCH_VOL     2        // GPIO2:touch2
#define BTN_CAL       0        // 校準按鈕(內部上拉,按下=GND)
#define LED_PIN       8
#define LED_NUM       8

Adafruit_NeoPixel strip(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);

const float F_MIN = 65.41f;    // C2,比吉他最低弦還低一度,夠用
const float F_MAX = 1046.5f;   // C6,四個八度
const uint32_t AUDIO_RATE = 44100;

/* --- 2. 正弦波表(256 點、線性內插補中間值) -------------------- */
static uint8_t sineLut[256];
static inline uint8_t sineAt(float phase /*0..1*/) {
  uint16_t p = (uint16_t)(phase * 255.99f);
  return sineLut[p];
}

/* --- 3. 觸控前端:中值濾波 → 基線追蹤 → 歸一化 ------------------- */
struct TouchChan {
  uint8_t  ch;             // touch 通道編號
  uint16_t buf[12];        // 中值濾波滑動窗
  uint8_t  idx = 0;
  float    baseline = 0;   // 「手不在」的背景值,只在無手時更新
  float    delta = 0;      // baseline - 當前值,越大=手越近
  bool    handNear = false;
  float   calOffset = 0;   // 校準存的補正值(處理桌面潮氣漂移)
};
TouchChan tcP = { .ch = TOUCH_PITCH }, tcV = { .ch = TOUCH_VOL };

/*  用途:讀一通道,做 12 點中值濾波+基線追蹤
 *  參數:t=通道   回傳:0..1 接近度(死區已扣除)
 *  為什麼中值:觸控偶發跳變(Wi-Fi 沒有、但喇叭電流脈衝有)是單點
 *  尖刺,均值濾波會被拉偏,中值免疫;為什麼基線只在「無手」時追:
 *  手懸停 30 秒會被慢速 EMA 當背景吃掉音高——樂器不能這樣背叛玩家 */
float touchScan(TouchChan& t) {
  uint16_t raw = touchRead(t.ch);
  t.buf[t.idx++] = raw;
  if (t.idx == 12) {
    t.idx = 0;
    // 插入排序取中值(12 個元素,代價可忽略)
    for (int a = 1; a < 12; a++) {
      uint16_t k = t.buf[a]; int b = a - 1;
      while (b >= 0 && t.buf[b] > k) { t.buf[b+1] = t.buf[b]; b--; }
      t.buf[b+1] = k;
    }
    float med = t.buf[5] * 0.5f + t.buf[6] * 0.5f;
    float d = (t.baseline - med) - t.calOffset;
    if (d < 40) {                       // 死區:低於 40 counts 視為無手
      t.handNear = false;
      t.baseline += (med - t.baseline) * 0.003f;  // 慢速追環境漂移
      t.delta *= 0.85f;                            // 釋放斜率,防喀噪
    } else {
      t.handNear = true;
      t.delta = d;
    }
    return constrain(d / 900.0f, 0.0f, 1.0f);     // 900 counts ≈ 手掌 12cm
  }
  return t.delta / 900.0f;
}

/* --- 4. 音頻任務:44.1kHz 波表輸出(最高優先權) ----------------- */
volatile float gFreq = 440.0f;   // 由控制任務寫入,音頻任務只讀
volatile float gVolTarget = 0;   // 目標包絡
TaskHandle_t hAudio;

static void audioTask(void*) {
  int64_t next = esp_timer_get_time();
  float phase = 0, env = 0;
  while (true) {
    float v = gVolTarget;
    // attack 20ms / release 150ms:起手不「啪」、放手不「噠」
    float k = (v > env) ? 0.012f : 0.0016f;
    env += (v - env) * k;
    if (env < 0.004f) env = 0;               // 靜音徹底,不留殘嘶
    uint8_t s = sineAt(phase);
    int out = 128 + (int)((s - 128) * env);  // 以 128 為 DC 偏置
    dac_output_write(DAC1, (uint8_t)constrain(out, 0, 255));
    phase += gFreq / AUDIO_RATE;
    if (phase >= 1.0f) phase -= 1.0f;
    next += 1000000LL / AUDIO_RATE;
    int64_t wait = next - esp_timer_get_time();
    if (wait > 1000) vTaskDelay(wait / 1000 / portTICK_PERIOD_MS);
  }
}

/* --- 5. 控制任務:觸控掃描 → 音高/音量映射 ----------------------- */
Preferences nvs;
float slewedFreq = 440.0f;

/*  用途:指數音高映射+斜率限制
 *  為什麼指數:線性映射=低音區手指一寸八度、高音區一毫米半音,
 *  難彈到懷疑人生;指數映射下「等距離=等音程」才是樂器
 *  為什麼斜率限制:觸控殘餘抖動直接轉成頻率會變成廉价 vibrato,
 *  限制每秒變化速率後,快速揮手仍跟得上,靜手如磐石 */
void controlTask(void*) {
  float lastNote = -1;
  while (true) {
    float p = touchScan(tcP), v = touchScan(tcV);
    if (tcP.handNear) {
      float target = F_MIN * powf(F_MAX / F_MIN, constrain(p, 0.02f, 1.0f));
      float maxStep = target * 0.012f;            // 每 2ms 最多 ±1.2%(≈每秒兩個八度)
      if (fabsf(target - slewedFreq) > maxStep)
        slewedFreq += (target > slewedFreq ? maxStep : -maxStep);
      else slewedFreq = target;
      gFreq = slewedFreq;
      gVolTarget = tcV.handNear ? constrain(v * 1.35f, 0.12f, 1.0f) : 0.0f;
    } else {
      gVolTarget = 0;                              // 右手離開=不發聲
    }
    // WS2812 音高譜:音名 → 色相(C 紅 D 橙 E 黃 F 綠 G 青 A 藍 B 紫)
    if (tcP.handNear) {
      float note = 12.0f * log2f(slewedFreq / 16.35f);   // MIDI 音名浮點
      int ni = ((int)roundf(note)) % 12;
      if (ni != (int)lastNote) {
        lastNote = ni;
        uint8_t hue = (uint8_t)(ni * 21);
        for (int i = 0; i < LED_NUM; i++)
          strip.setPixelColor(i, strip.ColorHSV(hue * 256, 255,
              140 + 80 * (float)i / LED_NUM));
        strip.show();
      }
    } else if (millis() % 2000 < 30) {            // 無手:呼吸一下表示活著
      strip.clear(); strip.setPixelColor(0, 12, 40, 20); strip.show();
    }
    vTaskDelay(2 / portTICK_PERIOD_MS);            // 500Hz 控制環
  }
}

/* --- 6. 校準:長按 2 秒記下當前基線 ----------------------------- */
void tryCalibrate() {
  if (digitalRead(BTN_CAL)) return;
  uint32_t t0 = millis();
  while (!digitalRead(BTN_CAL) && millis() - t0 < 5000) vTaskDelay(5);
  if (millis() - t0 < 2000) return;               // 不足 2 秒=誤按
  // 雙手離開狀態下採 30 次平均,差值寫入 NVS
  uint32_t b1 = 0, b2 = 0;
  for (int i = 0; i < 30; i++) { b1 += touchRead(TOUCH_PITCH);
                                  b2 += touchRead(TOUCH_VOL); vTaskDelay(5); }
  nvs.putULong("calP", b1 / 30);
  nvs.putULong("calV", b2 / 30);
  for (int i = 0; i < LED_NUM; i++) { strip.setPixelColor(i, 0, 255, 120);
                                      strip.show(); vTaskDelay(40); }
  esp_restart();                                   // 重開機吃新校準,最乾淨
}

/* --- 7. setup ---------------------------------------------------- */
void setup() {
  Serial.begin(115200);
  pinMode(BTN_CAL, INPUT_PULLUP);
  strip.begin(); strip.setBrightness(90); strip.clear(); strip.show();
  for (int i = 0; i < 256; i++)                    // 波表:一個完整正弦
    sineLut[i] = 128 + (uint8_t)(127.0f * sinf(i * 2.0f * PI / 256.0f));

  nvs.begin("theremin", true);
  uint32_t cp = nvs.getULong("calP", 0), cv = nvs.getULong("calV", 0);
  uint32_t r1 = 0, r2 = 0;
  for (int i = 0; i < 30; i++) { r1 += touchRead(TOUCH_PITCH);
                                 r2 += touchRead(TOUCH_VOL); vTaskDelay(5); }
  r1 /= 30; r2 /= 30;                              // 開機 10 秒讓觸控收斂
  tcP.baseline = cp ? cp : r1;
  tcV.baseline = cv ? cv : r2;
  tcP.calOffset = cp ? 0 : 0;                      // 校準值即基線,補正歸零
  Serial.printf("base P=%lu V=%lu\n", (unsigned long)tcP.baseline,
                              (unsigned long)tcV.baseline);

  // 音頻任務:最高優先權釘在唯一核心上;控制任務讓它半级
  xTaskCreatePinnedToCore(audioTask,  "audio", 4096, NULL, 24, &hAudio, 0);
  xTaskCreatePinnedToCore(controlTask,"ctrl",  4096, NULL, 12, NULL, 0);
}

void loop() {
  tryCalibrate();
  delay(20);
}
⚠️ 編譯提醒:dac_output_write(DAC1, v) 需要 core ≥ 2.0.9;若你的板子管理員是 3.x(IDF 5)核心,改一行 analogSetPinAttenuation 系 API 或直接用 ledcWrite 20MHz PWM 加第三階 RC(音質略降,實測差距小)。S2 的 touchRead() 在 3.x 改名 touchRead() 沿用,不用驚慌。

🔧 組裝步驟

  1. 列印就緒:六個列印件+燈條壓條;號角頂孔先試插鋁棒——应该是「用手掌推进去的紧」,太鬆點熱熔膠,太緊砂紙修孔。
  2. 做迴路:漆包線在鋁棒根部繞三圈(Ø≈2.5cm),尾端鉑金線直接焊成一圈閉合;預留 12cm 引線穿過號角內走線槽,剝漆上錫後焊 26AWP silikon 軟線進板子 GPIO1。
  3. 音量迴路同法:焊 GPIO2。兩根天線棒裝上號角、鎖底座——此時先別鎖死,留校準餘裕。
  4. 音頻鏈:洞洞板焊兩階 RC(1kΩ/100nF ×2),輸出接 PAM8403 IN+,喇叭接 OUT;PAM8403 供電直接從 USB 分線,不要走板子的 5V 軌——功放電流從板子繞路會把 DAC 電源弄髒。
  5. 燈條:DIN 串 330Ω 焊 GPIO8,供電端焊 1000µF;壓條卡進底座前沿。
  6. 燒機與校準:燒韌體 → 開機 10 秒雙手離開 → 按 2 秒校準(燈條跑綠光)→ 關機 10 分鐘再驗一次(電容基線會隨溫度微漂,第二輪校準才是工作基線)。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書

🧪 驗證與調音

測試動作合格線
靜默基底開機靜置 10 秒,貼喇叭聽完全無聲(有嘶聲=包絡靜音沒歸零或电源髒)
音高行程右手從 15cm 壓到貼頂C2 平滑滑到 C6 不跳音、不回彈
音量閘左手移開150ms 內歸零,尾音無「噠」
串音只動右手,Serial 看 V 通道 delta< 50 counts(超過=兩迴路太近或走線平行)
校準存檔校準後拔電 30 秒重插10 秒內直接可奏,不需重校
音準抽考手機裝調音器 App,滑到 A4 附近停手±30 cent 內(波表 8-bit 量化底噪不影響音準)

🆘 問題排除

症狀最可能的原因處方
完全沒聲音DAC 腳焊錯(GPIO17 不是 GPIO18 之外都不出聲)/PAM8403 音量鈕關到 minimumSerial 印 gFreq 確認有值;拿萬用表量 GPIO17 應為 ~1.65V(中點偏置)
音高自己慢慢往下掉基線在「手懸停」時也在追——校準時機不對(校準時手就放在天線上)重校準:確保雙手離開再按鈕;環境潮氣大的日子每天重校一次
兩隻手互相干擾迴路中心距 < 12cm、或引線在殼內平行走線拉開號角角度;引線絞成麻花再進殼
有規律的嗡嗡(地噪)火牛漏電式濾波+筆電 USB 地迴路換獨立 5V 火牛供電;或加 10µF 隔直電解在 DAC 輸出端
高音區毛刺明顯8-bit DAC 量化噪聲在高频失真更显——两階 RC 焊錯成 1kΩ+10nF(fc 跑到 16kHz)核對電容標示;必要時第三階 RC 再衰 12dB
開機嗶一聲基線未收斂時包絡搶跑已是過去式:本韌體開機先採基線再起音頻任務,若你自己改回單 loop 架構就會遇到它

📁 專案檔案結構

theremin/
├── firmware/
│   └── theremin.ino             # 上方完整韌體
├── stl/
│   ├── base-15deg.stl           # 底座(15° 上翹+配重槽)
│   ├── horn-left.stl            # 左號角(音量)
│   ├── horn-right.stl           # 右號角(音高)
│   ├── led-diffuser.stl         # 燈條導光壓條
│   └── board-stand.stl          # 板托+功放夾
├── f3d/
│   └── theremin.f3d             # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C6 電動車充電安全哨兵 📚 系列索引 下一篇:ESP32-C3 床頭地震警報塔 →