📋 專案概述
兒子學琴第三個月,開始每週固定出現一次「手指痛」。我沒買第二台電鋼的預算,卻一直想讓他玩一樣不需要精準按鍵的東西——上週陪他看《生活大爆炸》,Sheldon 兩只手懸在空氣裡「彈」出一段旋律,他在沙發上整個人跳起來:「這個不用按!」那一幕就是特雷門(Theremin),1920 年代發明的第一支電子樂器,也是到今天看起來還最像魔法的一支。
於是我認認真真查了一晚上貨架:
- 正经特雷門(MOEEUS THEREMIN V2 那一類):台幣 3,900 元起。買一台「可能彈兩次就收進櫃子」的樂器給一個剛說手指痛的八歲小孩?這筆帳算不通。
- 淘寶玩具特雷門:900 元。評價區全是「音準像喝醉」「兩根天線互相打架」「調完 A 音 B 音跟著跑」——它的本體是兩顆 555 振盪器外差,漂移是天性,不是品控問題。
- DIY 古法(555/CD4011 雙振盪差頻):材料 300 元,但要拿示波器把兩顆振盪器調到彼此不_pull-in_、再跟溫度掙扎——那不是做樂器,是拜師。
然後我看見 ESP32 家族裡一個長年被冷落型號的規格表:ESP32-S2 內建最多 14 通道電容式觸覺感應(Touch Sensor),而且內建兩顆 8-bit DAC。「手掌隔空改變電容」正是特雷門天線物理學的一模一樣那條公式,「DAC 直出音頻」正是揚聲器要的東西——一支 110 元的板子把這台的兩條腿都買齊了。於是有這篇:
- 右迴路=音高:手在天線上空上下移動,65.4Hz(C2)~1046Hz(C6)四個八度,指數映射——手指等速移動,音樂上是等音程滑行,這是樂器起碼的禮貌
- 左迴路=音量:越近越響,手完全離開 150ms 內歸零;靜音時真的靜音,不是「很小聲的咝咝」
- WS2812 八燈音高譜:C 紅 D 橙 E 黃按音名上色,孩子「看得見」自己滑到哪兩個音——順便把「為什麼剛剛那個音聽起來怪怪的」變成可以討論的事
- 44.1kHz 波表合成:256 點正弦查表+線性內插,DAC 經兩階 RC 抗頻疊後由 PAM8403 推 2W 喇叭;拔喇叭可插小擴大機或耳機
- 一鍵校準存 NVS:按兩秒,把當下環境的電容基準記進快閃,斷電不丟;换桌子和春天搬去陽台都不用重開機調半天
- 三層防抖:12 點中值濾波 → 基線只追「手離開時」的慢速移動平均 → 死區+斜率限制;音高不再喝醉,這是打敗 900 元玩具的全部秘密
帳目: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Ω×1 | 1 組 | ~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 元 |
🔌 接線圖
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 偏置 |
| GPIO8 | WS2812 DIN(330Ω) | 避開 GPIO19/20(USB D±),SuperMini 改版同腳位可用 |
| GPIO0 | 校準按鈕 → GND | S2 的 GPIO0 非 strapping 腳,按住開機不會變砖 |
| 5V / GND | PAM8403 供電、燈條供電 | 燈條 8 顆全亮峰值約 160mA,加喇叭 2W,火牛要老實 1A |
🧊 3D 列印設計
| 零件 | 材質 | 列印設定 | 時間 | 備註 |
|---|---|---|---|---|
| 底座(15° 上翹) | PLA | 0.2mm/20% 菱形填充/底部實心 4 層 | ~100 分 | 預留 60g 配重槽(螺母或備用 18650),不然兩手一靠整台前翻 |
| 右號角錐 | PLA | 0.2mm/樹狀支持/頂孔 Ø10.2mm | ~80 分 | 內壁預留漆包線走線槽(寬 1mm 深 1mm) |
| 左號角錐 | PLA | 同右號角(互為鏡像) | ~80 分 | 兩迴路中心距 19cm(實測串音最小值) |
| 燈條導光壓條 | 透明 PETG | 0.2mm/實心 | ~25 分 | 透明 PLA 易黃,PETG 透光久一點 |
| 板托+功放夾 | PLA | 0.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,开机即奏——乐器的开机时间应该是一眨眼,不是「等待網路中」。
💡 本機完全離線:整台不連 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() 沿用,不用驚慌。🔧 組裝步驟
- 列印就緒:六個列印件+燈條壓條;號角頂孔先試插鋁棒——应该是「用手掌推进去的紧」,太鬆點熱熔膠,太緊砂紙修孔。
- 做迴路:漆包線在鋁棒根部繞三圈(Ø≈2.5cm),尾端鉑金線直接焊成一圈閉合;預留 12cm 引線穿過號角內走線槽,剝漆上錫後焊 26AWP silikon 軟線進板子 GPIO1。
- 音量迴路同法:焊 GPIO2。兩根天線棒裝上號角、鎖底座——此時先別鎖死,留校準餘裕。
- 音頻鏈:洞洞板焊兩階 RC(1kΩ/100nF ×2),輸出接 PAM8403 IN+,喇叭接 OUT;PAM8403 供電直接從 USB 分線,不要走板子的 5V 軌——功放電流從板子繞路會把 DAC 電源弄髒。
- 燈條:DIN 串 330Ω 焊 GPIO8,供電端焊 1000µF;壓條卡進底座前沿。
- 燒機與校準:燒韌體 → 開機 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 音量鈕關到 minimum | Serial 印 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 # 說明文件