
EDA-Piano简易电子琴🎹
简介
本项目基于ESP8266模组开发,是一款物联网电子琴,支持可视化教学及可视化播放等。
简介:本项目基于ESP8266模组开发,是一款物联网电子琴,支持可视化教学及可视化播放等。开源协议
:GPL 3.0
描述
本项目的硬件成本来说应该是比较低的,主要的部分就是ESP8266和SC12B,TP4056不需要也可以删掉。
项目功能
ESP8266具有强大的 WiFi 功能和丰富的 GPIO 接口,本项目充分利用这些特性,实现了一个功能完整的智能钢琴系统。通过 SC12B 触摸芯片实现 12 键同时检测,结合蜂鸣器音频输出和 OLED 显示,为用户提供完整的钢琴演奏体验。
- ✅ 支持12键同时触摸检测,实现和弦演奏
- ✅ OLED实时显示当前按键和音符信息
- ✅ Web界面远程控制,支持手机操作
- ✅ 教学模式,内置小星星、两只老虎等经典曲目,OLED预览教学
- ✅ 混音播放,支持多音符同时发声
- ✅ 可调节音符持续时间、八度偏移、触摸灵敏度
- ✅ 自动播放功能,可播放预设曲目
项目参数
- 采用ESP8266作为主控,内置WiFi功能
- SC12B触摸芯片,支持12路电容触摸检测
- 128x32 OLED显示屏,实时显示演奏信息
- 无源蜂鸣器音频输出,支持多音符混音
- 支持5个八度音域,共60个音符
- Web界面支持响应式设计,适配手机和电脑
硬件设计
- Flash:4MB


采用SC12B触摸检测芯片,支持12路电容触摸检测,并且自带消抖处理,支持持自动校正,2.5V ~ 6.0V 宽电压。
通过I2C接口与主控通信,可同时检测多个按键按下状态,实现和弦演奏功能。
- 检测通道:12路
- 通信接口:I2C
- 检测精度:高精度电容检测
- 响应时间:<10ms
- 支持同时多键检测
原理图

PCB设计
触控PAD正面
在电容触控的PCB设计中为了使其有较强的抗干扰能力,本项目触控PAD与铺地间距控制在1.5mm,使其有效平衡系统抗干扰度和触控灵敏度。


触控PAD背面
在电容触控PAD的背面做了镂空处理,减少寄生电容,改善灵敏度,在触控区和主电路区域放置地过孔隔离。

走线规则
对于相邻触摸信号线距离及铺地距离设置在15mil,避免串扰


对于触控信号线走线线宽设置为5mil

所有信号线均不跨越其他信号线,走线周围0.5mm内不走其他信号线

如果想让触控延时尽量保持一致,还可为每条触控信号线设置等长处理
封装
钢琴键已设计成封装,方便引用。

丝印部分参考:https://oshwhub.com/47uF/mini_piano 工程修改
- 分辨率:128x32像素
- 驱动芯片:SSD1306
- 通信接口:I2C
- 显示内容:钢琴键盘、音符、状态信息

- 输出设备:无源蜂鸣器
- 驱动方式:PWM
- 音域范围:5个八度
- 支持功能:单音、和弦、混音





软件开发
- 软件环境:VSCode + PlatformIO
- 开发语言:C/C++
- 框架:Arduino Framework
- Adafruit SSD1306:用于OLED显示屏驱动
- ESPAsyncWebServer:用于Web服务器功能
- ArduinoJson:用于JSON数据处理
- SC12B:基于liuquanli1970/SC12B开源库修改适配
- audio.h/.cpp:音频播放和混音处理
- display.h/.cpp:OLED显示控制
- touch.h/.cpp:触摸检测和多键处理
- network.h/.cpp:WiFi和Web服务器
- music.h/.cpp:曲谱数据和播放控制
- SC12B.h/.cpp:触摸芯片驱动
- config.h:系统配置参数
- main.cpp:主程序入口
使用方法
- 触摸演奏: 直接触摸对应按键演奏
- OLED实时预览:预览按下的按键

- 虚拟琴键: 支持web端远控弹奏
- 歌曲演奏: 支持对预载歌曲的音乐演奏功能
- 教学模式: 支持预载歌曲的钢琴弹奏教学,会在OLED屏实时反馈。
- 音频参数调节: 音符时长、八度偏移、灵敏度调节。

// 硬件引脚定义
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define TOUCH_INTERRUPT_PIN 14
#define BUZZER_PIN 12
// 网络配置
#define AP_SSID "EDA-Piano"
#define AP_PASSWORD ""
// 音频参数
#define DEFAULT_NOTE_DURATION 500
#define DEFAULT_OCTAVE_SHIFT 0
SC12B一共支持两种控制方法,分别是I2C控制和BCD端口输出,BCD端口输出很简单,只需要ADC检查电压就可以,但BCD的缺点也很明显,四个接口要配置四个不常见的电阻,而且只能单向输出触控信号,无法深入控制IC,并且BCD产生的模拟电压也无法检查多点触控,显然不符合本项目要求。因此这里我们选择IIC控制。


基于liuquanli1970/SC12B提供的库移植修改
SC12B.h
这里我们将writeRegister移动到public公开,方便外部调用。
public:
bool writeRegister(uint8_t reg, uint8_t value);
SC12B.cpp
这里我们将begin函数中内容修改成如下,使用默认地址和默认IIC。
void SC12B::begin() {
Wire.begin();
}
寄存器列表


依照数据手册提供的寄存器配置,我们在软件端设置了16个触控等级
// 应用触摸灵敏度设置到SC12B芯片
void applyTouchSensitivity() {
Sensitivity sensitivityLevel;
switch(touchSensitivity) {
case 0: sensitivityLevel = LEVEL0; break;
case 1: sensitivityLevel = LEVEL1; break;
case 2: sensitivityLevel = LEVEL2; break;
case 3: sensitivityLevel = LEVEL3; break;
case 4: sensitivityLevel = LEVEL4; break;
case 5: sensitivityLevel = LEVEL5; break;
case 6: sensitivityLevel = LEVEL6; break;
case 7: sensitivityLevel = LEVEL7; break;
case 8: sensitivityLevel = LEVEL8; break;
case 9: sensitivityLevel = LEVEL9; break;
case 10: sensitivityLevel = LEVEL10; break;
case 11: sensitivityLevel = LEVEL11; break;
case 12: sensitivityLevel = LEVEL12; break;
case 13: sensitivityLevel = LEVEL13; break;
case 14: sensitivityLevel = LEVEL14; break;
case 15: sensitivityLevel = LEVEL15; break;
default: sensitivityLevel = LEVEL0; break;
}
touchPannel.writeRegister(REG_Senset0, sensitivityLevel);
touchPannel.writeRegister(REG_SensetCOM, sensitivityLevel);
}
touchPannel.writeRegister(REG_Senset0, sensitivityLevel);
写入传感器0的灵敏度寄存器
touchPannel.writeRegister(REG_SensetCOM, sensitivityLevel);
写入公共传感器的灵敏度寄存器
寄存器定义 (在 SC12B.h 中)
// 寄存器地址定义
#define REG_Senset0 0x00 // 传感器0灵敏度寄存器
#define REG_SensetCOM 0x01 // 公共传感器灵敏度寄存器
// 灵敏度等级枚举
typedef enum {
LEVEL0 = 0x04, // 最低灵敏度
LEVEL1 = 0x15,
LEVEL2 = 0x25,
// ... 其他等级
LEVEL15 = 0xFF // 最高灵敏度
} Sensitivity;
而在硬件中还有一个可调电容可以调节灵敏度



对于触控的检查我们将使用上面的引脚,地址参考地址选择说明,本项目中ASEL浮空,INT用于中断检测,当INT触发硬件中断则说明有按键被触摸,此时发送IIC轮询找到对应通道即可。
/* ========== 中断驱动的触摸检测 ========== */
if (iftouch) {
iftouch = false;
unsigned long currentTime = millis();
if (currentTime - lastSampleTime > SAMPLE_INTERVAL) {
uint16_t keyValue = detectMultipleKeys();
if (keyValue != previousKeys) {
currentKeys = keyValue;
previousKeys = keyValue;
lastKeyTime = currentTime;
int pressedKeys[12];
int keyCount = 0;
parseKeys(keyValue, pressedKeys, &keyCount);
// 教学模式处理
if (teachingMode && keyCount > 0) {
int* melody = getCurrentMelody();
int melodyCount = getCurrentMelodyCount();
int expectedNote = melody[currentNoteIndex];
if (expectedNote == 0) {
// 跳过休止符
currentNoteIndex++;
if (currentNoteIndex < melodyCount) {
expectedNote = melody[currentNoteIndex];
}
}
if (keyCount == 1 && pressedKeys[0] == expectedNote) {
// 按对了
currentNoteIndex++;
if (currentNoteIndex >= melodyCount) {
showTeachingMode(0, true, "Complete!");
teachingMode = false;
} else {
int nextNote = melody[currentNoteIndex];
if (nextNote == 0 && currentNoteIndex + 1 < melodyCount) {
currentNoteIndex++;
nextNote = melody[currentNoteIndex];
}
showTeachingMode(nextNote, true, "Good!");
}
} else {
// 按错了
showTeachingMode(expectedNote, false, "Error!");
}
} else if (teachingMode) {
// 教学模式但没有按键,显示下一个要按的键
int* melody = getCurrentMelody();
int melodyCount = getCurrentMelodyCount();
int nextNote = melody[currentNoteIndex];
if (nextNote == 0 && currentNoteIndex + 1 < melodyCount) {
currentNoteIndex++;
nextNote = melody[currentNoteIndex];
}
showTeachingMode(nextNote, false, "");
} else {
// 正常模式
displayMultipleKeys(pressedKeys, keyCount);
}
if (keyCount > 0) {
playMultipleNotes(pressedKeys, keyCount);
} else {
stopAllAudio();
}
}
lastSampleTime = currentTime;
}
}
主机发送:
START -> 0x40(写) -> ACK -> 0x08(REG_OUTPUT1) -> ACK -> RESTART -> 0x41(读) -> ACK
从机响应:
DATA1 -> ACK -> STOP
多次采样:
连续5次轮询避免单次采样的不稳定性
1ms间隔确保采样的时间分散性
防抖处理:
20ms防抖时间避免按键抖动
只有状态真正改变才处理
这部分不细讲了,和前面EDA-Robot的项目的基本一致,移植过来的,
通过路由创建RESTful API,然后页面按钮触发发get/post,由路由监听到后执行对应任务。
void setupWebServer() {
// 主页面
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "text/html", generateMainPage());
});
// 状态API
server.on("/status", HTTP_GET, [](AsyncWebServerRequest *request){
String json = "{\"duration\":" + String(noteDuration) + ",\"octave\":" + String(octaveShift) + ",\"sensitivity\":" + String(touchSensitivity) + ",\"wifi\":" + String(wifiConnected ? "true" : "false") + "}";
request->send(200, "application/json", json);
});
// 播放音符API
for(int i = 1; i <= 12; i++) {
String path = "/play/" + String(i);
server.on(path.c_str(), HTTP_GET, [i](AsyncWebServerRequest *request){
webCommand = i;
webCommandPending = true;
request->send(200, "text/plain", "OK");
});
}
// 和弦API
server.on("/chord/1,5,8", HTTP_GET, [](AsyncWebServerRequest *request){ webCommand = 201; webCommandPending = true; request->send(200, "text/plain", "OK"); });
server.on("/chord/6,10,1", HTTP_GET, [](AsyncWebServerRequest *request){ webCommand = 202; webCommandPending = true; request->send(200, "text/plain", "OK"); });
server.on("/chord/8,12,3", HTTP_GET, [](AsyncWebServerRequest *request){ webCommand = 203; webCommandPending = true; request->send(200, "text/plain", "OK"); });
// 歌曲播放API
server.on("/song/play", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("id")) {
int songId = request->getParam("id")->value().toInt();
setCurrentSong(songId);
webCommand = 300; webCommandPending = true;
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Missing song ID");
}
});
// 教学模式API
server.on("/teaching/start", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("id")) {
int songId = request->getParam("id")->value().toInt();
setCurrentSong(songId);
webCommand = 400; webCommandPending = true;
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Missing song ID");
}
});
// 控制命令
server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request){
webCommand = 100; webCommandPending = true; request->send(200, "text/plain", "OK");
});
// 设置API
server.on("/set/duration", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("value")) {
int dur = request->getParam("value")->value().toInt();
if (dur >= 100 && dur <= 2000) {
noteDuration = dur;
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Invalid duration");
}
} else {
request->send(400, "text/plain", "Missing value parameter");
}
});
server.on("/set/octave", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("value")) {
int oct = request->getParam("value")->value().toInt();
if (oct >= -2 && oct <= 2) {
octaveShift = oct;
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Invalid octave");
}
} else {
request->send(400, "text/plain", "Missing value parameter");
}
});
server.on("/set/sensitivity", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("value")) {
int sens = request->getParam("value")->value().toInt();
if (sens >= 0 && sens <= 15) {
touchSensitivity = sens;
applyTouchSensitivity();
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Invalid sensitivity");
}
} else {
request->send(400, "text/plain", "Missing value parameter");
}
});
server.begin();
}
安装结构
项目采用的是双外壳结构,由前盖、后盖构成

| 正面 | 背面 |
|---|---|
![]() | ![]() |
- 前盖板内部空间很足,可以放下喇叭和电池
- 圆角处理
- 为typeC开槽
| 正面 | 背面 |
|---|---|
![]() | ![]() |
- 后盖内部空间较足,可以放下电池
- 圆角处理
- 为PCB设置基座
| 实物图 | 拆解图 |
|---|---|
![]() | ![]() |
| 教学模式-正反馈 | 教学模式-负反馈 |
![]() | ![]() |
- 增加更多内置曲目和教学内容
- 支持MIDI输出,播放加钢琴教学
- 支持自定义音色和乐器声音
- 添加节拍器和调音器功能
- 添加功放或大喇叭扩音量
- 添加TF卡存储歌曲
其实我本来想做MIDI这个的,因为MIDI格式是可以映射到琴键的,可以接个max98357,然后上位机解析MIDI到json格式,再去把数据处理就能映射了,这样你就能直接导入MIDI格式的音乐去学习弹奏。
"tracks": [
{
"startTime": 0,
"duration": 0,
"length": 0,
"notes": [],
"controlChanges": {},
"id": 0
},
{
"startTime": 1.6640625,
"duration": 197.63932291666669,
"length": 746,
"notes": [
{
"name": "G1",
"midi": 31,
"time": 1.6640625,
"velocity": 0.5118110236220472,
"duration": 2.2604166666666665
},
{
"name": "G2",
"midi": 43,
"time": 1.6653645833333333,
"velocity": 0.5118110236220472,
"duration": 2.260416666666667
},
{
"name": "G3",
"midi": 55,
"time": 1.6770833333333333,
"velocity": 0.4409448818897638,
"duration": 2.26171875
},
{
"name": "D3",
"midi": 50,
"time": 2.2109375,
"velocity": 0.5826771653543307,
"duration": 1.7278645833333335
},
{
"name": "B3",
"midi": 59,
"time": 2.7877604166666665,
"velocity": 0.6850393700787402,
"duration": 1.143229166666667
},
比如这组midi解析的数据,我们可以得到
G1 (midi: 31) - 低音G
G2 (midi: 43) - 中音G
G3 (midi: 55) - 高音G
D3 (midi: 50) - D音
B3 (midi: 59) - B音
这就能映射到琴键上了,但我们这里的项目主要是入门为主,所以这里我给大家提供一个思路,大家可以自己去实现,如果后续有空的话可以出个pro版,当然如果你有更好的方法也可以自己尝试。
- 烧录部分可以参考 EDA-Robot机器狗-烧录教程
- 除了触摸外,手尽量不要触碰到PCB下半部分,避免干扰触摸信号,建议装好外壳使用
- 如果声音太小建议更换喇叭或添加功放,外壳安装时把喇叭粘在外壳透声孔上。
- 如果想降低成本可以去掉TP4056充电电路,TypeC的5V直接接到LDO输入上。喇叭也可换成蜂鸣器替代,不需要显示也可以把屏幕去掉。
设计图
未生成预览图,请在编辑器重新保存一次BOM
暂无BOM
克隆工程知识产权声明&复刻说明
本项目为开源硬件项目,其相关的知识产权归创作者所有。创作者在本平台上传该硬件项目仅供平台用户用于学习交流及研究,不包括任何商业性使用,请勿用于商业售卖或其他盈利性的用途;如您认为本项目涉嫌侵犯了您的相关权益,请点击上方“侵权投诉”按钮,我们将按照嘉立创《侵权投诉与申诉规则》进行处理。
请在进行项目复刻时自行验证电路的可行性,并自行辨别该项目是否对您适用。您对复刻项目的任何后果负责,无论何种情况,本平台将不对您在复刻项目时,遇到的任何因开源项目电路设计问题所导致的直接、间接等损害负责。


















