用ESP32 制作音乐合成器
在故事的第第一部分中,我解释了如何使用ESP32和I2S音频芯片播放音乐。今天我们将做一些完全不同的事情。我们将把同一个ESP32编程为MIDI键盘使用!这将允许我们将ESP32与专业级软件(如GarageBand)配合使用,使用各种乐器和效果器。
由于我们不再需要高速音频处理,所有编码都将用Python完成。如果你想拥有一个真实的设备进行测试,我还会在文章末尾展示如何订购专业级的PCB。
1. 硬件
硬件需求并不高。我们需要一个ESP32开发板、一根USB线、一块面包板、一些触摸传感器(或铜箔)和电阻。ESP32内置了电容式触摸感应外设,我们可以将其用作电子琴的"琴键"。
ESP32 是一款小巧且价格实惠(约 10 美元)的开发板,非常适合各种硬件和物联网实验。它配备双核 32 位 CPU,运行频率约为 200 MHz,拥有约 512 KB 的 RAM,以及我们将要使用的大量 GPIO 端口。
由于我们使用 MIDI 而非原始音频生成,因此当前任务变得更加简单。我们不再需要音频芯片,当用户按下或松开琴键时,我们只需发送一个 MIDI 命令即可。实际上,原理图如下所示,左下角部分已不再需要:
本项目灵感来源于 20 世纪 60 年代的乐器——电子琴(Stylophone)。正如我们所见,几乎所有 ESP32 引脚都用作钢琴键。我们还有一个“模式”按钮,可以切换乐器。如果 15 个音符不够用,还可以使用可选的“八度”键来演奏高八度的音符。我们还可以使用左侧的旋钮来调节音量。最后,LED 指示灯用作“心跳”,方便我们查看电路板是否正常工作。
在第一部分中,我已经创建了一个 PCB,KiCad 项目已上传至 GitHub。电路板如下图所示:
如前所述,MIDI 项目不需要扬声器和音频芯片,因此您甚至可以为键盘添加更多琴键。您可以随意修改文件!文章末尾我会介绍如何订购真正的 PCB,但这并非必须。您也可以直接将 ESP32 插入面包板,它也能正常工作。
2、编码
现在,我们可以开始编写 Python 代码了!ESP32 可以使用 MicroPython 进行编程,MicroPython 是一种专为低功耗设备优化的 Python 方言。如果您之前从未使用过 MicroPython,我建议您先阅读这篇文章:
物联网入门:ESP32 轻松上手——MicroPython 和 Arduino IDE
现在,让我们开始吧!
2.1 钢琴键
ESP32 的主要功能是处理钢琴键,所以我们来实现它。首先,我将列出我拥有的引脚:
from machine import Pin
KEYS_PHYSICAL = 15
KEY_PINS = [39, 19, 21, 18, 34, 35, 33, 25, 17, 26, 5, 16, 4, 15, 27]
如果您使用面包板,请根据您的实际接线情况更改引脚编号。
现在,让我们将这些按键初始化为输入:
key_pin_objs = []
# ESP32 limitation: some GPIO pins have no pull-up resistors
NO_PULLUPS = (39, 34, 35)
def init_gpio():
""" Initial hardware setup """
# Setup Keys
for pin_num in KEY_PINS:
if pin_num in NO_PULLUPS:
key_pin_objs.append(Pin(pin_num, Pin.IN)) # No pull-up available on these pins
else:
key_pin_objs.append(Pin(pin_num, Pin.IN, Pin.PULL_UP))这里,我将 ESP32 的引脚配置为输入,这样我们就可以读取它们的值“1”或“0”。一个上拉电阻将引脚连接到 3.3V,因此引脚的默认值为“1”。这样,我们的逻辑就反过来了:当琴键被按下时,引脚值为“0”。
2.2 钢琴键事件
现在,我们有了所有的输入,可以生成琴键被按下或释放的事件。
对于每个琴键,我将存储它的状态,可以是“空闲”或“按下”:
# Key states
KEY_IDLE = 0
KEY_PRESSED = 1
KEYS_PHYSICAL = 15
key_state = [0] * KEYS_PHYSICAL现在,我们可以读取所有数值:
def update_keys():
""" Update keys data (naive version - do not copy!)"""
for i in range(KEYS_PHYSICAL):
is_pressed = key_pin_objs[i].value() == 0
if is_pressed and key_state[i] == KEY_IDLE:
key_state[i] = KEY_PRESSED
on_key_down(i)
elif not is_pressed and key_state[i] == KEY_PRESSED:
key_state[i] = KEY_IDLE
on_key_up(i)
def on_key_down(key_index: int):
""" Key Down event """
print("onKeyDown", key_index + 1)
def on_key_up(key_index: int):
""" Key Up event """
print("onKeyUp", key_index + 1)ESP32 本质上是一个单核微控制器(虽然它还有一个第二核,但只能作为最后的手段来处理某些任务)。它没有操作系统,也没有其他后台进程,因此我们可以安全地在主循环中运行这段代码:
init_gpio()
while True:
update_keys()现在,我们可以运行这段代码……但它无法正常工作。实际上,按下并释放一个按键时,我们可能会收到 5 到 10 个事件,而不是一个。在现实世界中,按键并非理想状态,总会存在一些摩擦和弹跳。因此,即使只按下一次按键,也会收到一长串的 on_key_down 和 on_key_up 事件。
软件层面的解决方法很简单,叫做防抖。我们只需要忽略持续时间过短的状态变化即可。为此,我将添加第三个状态以及最后一次状态变化的时间戳:
# Key states
KEY_IDLE = 0
KEY_PRESSED = 1
KEY_RELEASED = 2
key_state = [0] * KEYS_PHYSICAL
key_last_change = [0] * KEYS_PHYSICAL
DEBOUNCE_DELAY_MS = 50我们的 update_keys 方法会稍微长一些:
def update_keys():
""" Update keys data """
for i in range(KEYS_PHYSICAL):
is_pressed = key_pin_objs[i].value() == 0
current_time = time.ticks_ms()
time_diff = time.ticks_diff(current_time, key_last_change[i])
if is_pressed:
if key_state[i] == KEY_IDLE:
key_state[i] = KEY_PRESSED
key_last_change[i] = current_time
on_key_down(i)
elif key_state[i] == KEY_RELEASED and time_diff < DEBOUNCE_DELAY_MS:
key_state[i] = KEY_PRESSED
key_last_change[i] = current_time
else:
if key_state[i] == KEY_PRESSED:
key_state[i] = KEY_RELEASED
key_last_change[i] = current_time
elif key_state[i] == KEY_RELEASED and time_diff > DEBOUNCE_DELAY_MS:
key_state[i] = KEY_IDLE
key_last_change[i] = current_time
on_key_up(i)现在,如果琴键被释放,我们不会立即发送 on_key_up 事件。相反,琴键的状态会变为“released”,然后根据接下来的情况,琴键可能会再次被按下,或者最终被释放并设置为“idle”。
2.3 音量旋钮
正如我们所见,每个钢琴键都可以被按下或释放,引脚值可以是“0”或“1”。读取音量旋钮的方式略有不同。这里,我们有一个模拟值,其范围为 0 到 3.3V。我们不需要在代码中进行电压转换,ESP32 内部的 ADC(模数转换器)会为我们完成所有工作:
from machine import ADC
amplitude = 0
VOLUME_INPUT = 32
adc_vol = ADC(Pin(VOLUME_INPUT))
adc_vol.atten(ADC.ATTN_11DB) # Read full 0-3.3V range现在,我们只需一行代码即可读取旋钮的值:
def update_volume():
""" Read volume level from the ADC """
global amplitude
# MicroPython ADC returns 0-4095 by default
amplitude = adc_vol.read()2.4 心跳 LED
现在,我们可以获取钢琴键事件和音乐音量。作为可选步骤,我们让 LED 闪烁,以便清楚地表明电路板工作正常。这里棘手的地方在于,我们不能直接这样做:
HEARTBEAT_LED = 23
heartbeat_led_pin = Pin(HEARTBEAT_LED, Pin.OUT, value=0)
heartbeat_step = 0
def show_heartbeat_led():
""" Show the heartbeat LED - naive version, do not copy """
heartbeat_led_pin.value(heartbeat_step)
heartbeat_step = 1 - heartbeat_step
time.sleep(1)
init_gpio()
while True:
update_keys()
show_heartbeat_led()显然,LED 会闪烁。但其他所有功能都会停止工作,因为这段代码阻塞了主线程。
相反,在 show_heartbeat_led 函数中,我们必须保存状态和上次更新时间。这样,我们就可以仅在需要时才更改 LED 的状态:
heartbeat_last_time_ms = 0
heartbeat_step = 0
def show_heartbeat_led():
""" Show the heartbeat LED """
global heartbeat_led_pin, heartbeat_last_time_ms, heartbeat_step
time_ms = time.ticks_ms()
durations_ms = [1000, 1000]
states = [1, 0]
# Update LED when time passed
if time.ticks_diff(time_ms, heartbeat_last_time_ms) >= durations_ms[heartbeat_step]:
heartbeat_step = (heartbeat_step + 1) % 2
heartbeat_led_pin.value(states[heartbeat_step])
heartbeat_last_time_ms = time_ms这种方法更复杂,但效果更好。首先,它不会阻塞线程。其次,它可以设置两个以上的状态和持续时间,例如,您可以更改 LED 闪烁模式,例如改为“短-短-长”,而无需更改代码逻辑。
我们也可以使用 asyncio 来完成类似的任务。在这种情况下,它的优势并不明显(asyncio 在我们需要在单个核心上并行处理大量请求时表现出色),但对于读者来说,这不失为一个自行测试的好方法。
3、MIDI
最后,我们即将进入有趣的部分。我们有一块 ESP32 开发板,可以获取钢琴键事件和音量。最后一步是将 MIDI 命令发送到 PC,以便音频软件可以处理这些数据。
MIDI(乐器数字接口)是一个古老的标准,发布于 20 世纪 80 年代初。然而,它仍然能够很好地完成任务,并且至今仍被广泛使用。
实际上,我们的任务很简单。首先,我们需要将“按键按下”和“按键抬起”事件作为 MIDI 命令发送出去:
MIDI_NOTE_ON = 0x90
MIDI_NOTE_OFF = 0x80
def send_midi_message(command: int, note: int, velocity: int):
""" Send a raw 3-byte MIDI message over USB Serial """
# Write raw bytes directly to the serial buffer
sys.stdout.buffer.write(bytes([command, note, velocity]))
提醒一下,我的键盘有 15 个键,看起来像这样:
我决定将最低的键设置为 C4,它的 MIDI 代码是 60。现在,我只需要在 on_key_down 和 on_key_up 事件处理程序中调用 send_midi_message 函数即可:
BASE_MIDI_NOTE = 60 # MIDI Note 60 is Middle C (C4)
def on_key_down(key_index: int):
""" Key Down event """
# Send MIDI Note On (Command, Note, Velocity as 0..127)
global amplitude
midi_note = BASE_MIDI_NOTE + key_index
midi_amplitude = amplitude // 32 # 0..4095 => 0..127
send_midi_message(MIDI_NOTE_ON, midi_note, midi_amplitude)
def on_key_up(key_index: int):
""" Key Up event """
# Send MIDI Note Off (Command, Note, Velocity 0)
midi_note = BASE_MIDI_NOTE + key_index
send_midi_message(MIDI_NOTE_OFF, midi_note, 0)代码很简单。我们有音符和音量,只需要向 MIDI 接口发送正确的命令即可。ESP32 开发板必须连接到 PC,它会自动被识别为 USB 串口。
4、测试
恭喜所有读到这里的读者!我们的编码部分已经完成,现在只需要将 MIDI 数据发送到音频软件。这可能与平台有关,至少在我的 Mac 上,GarageBand 无法使用来自串口的 MIDI 数据。为了解决这个问题,我使用了一个开源的 Python 串口-MIDI 桥接器应用程序,可以从 GitHub 下载:
这个 Python 脚本已经有 5 年的历史了,但它仍然运行良好。我只需要在 PC 上运行它,它就会将所有串口数据转发到 MIDI 接口。
之后,我在我的 Mac 上打开了一个免费的 GarageBand 应用程序,我能够播放音乐并录制音频轨道。
GarageBand 由苹果公司开发,如果您是 Windows 或 Linux 用户,则可能需要使用其他应用程序。如果您想了解其工作原理,文章末尾附有视频。
5、制作 PCB
作为给读者的额外福利,我将展示如何制作专业级 PCB 用于您自己的测试。这并非必须,您也可以在面包板上使用 ESP32,但用真正的琴键演奏音乐会更有乐趣。
要制作 PCB,您需要 Gerber 文件,可以使用 KiCad(免费开源软件)生成。本项目的 KiCad 文件可在我的 GitHub 页面上找到。在 KiCad 的 PCB 编辑器中,您可以查看电路板并根据需要进行任何更改:
使用“制造输出”菜单,您可以生成 Gerber 文件和钻孔文件,并将它们打包成一个压缩文件。
现在,我们可以订购PCB了,我会演示如何使用PCBWay——我在第一部分中已经用过的那家公司。很久以前,我曾经用激光打印机、化学试剂和烙铁自己制作PCB。虽然也能用,而且过程很有趣,但很脏乱,也不太可靠。现在,我们只需在线上传文件,几天后就能收到专业制造的PCB!
最简单的方法是上传包含Gerber文件的压缩包,所有参数都会自动计算:
我住在荷兰,写这篇文章时,运费是7.53美元。您的运费可能会有所不同,而且根据您所在的国家/地区,您可能还需要支付邮资税。
您可以选择调整许多设置,但我都保留了默认设置:
实际生产需要24小时。显然,我也得等邮递,过了一段时间,电路板终于到了:
细心的读者可以看到,PCB 版本是 0.1,而上面的截图显示的是 0.2 版本。这是我的疏忽,我忘记为按键指定导电掩膜(您可以在下面的视频中看到我是如何修复的)。不过电路板的质量很好,PCBWay 也按照文件中的说明制作了电路板,所以我没什么可抱怨的。
最后,我在 PCBWay 的页面上看到,该公司将于 2026 年 7 月迎来 12 周年庆,并为新订单提供一些折扣。我已经收到电路板了,对我来说已经太晚了,但如果您在 2026 年 7 月看到这篇文章,或许会有帮助。声明一下,我并没有从他们的销售中获得任何利润,所以请您自行访问 PCBWay 的网站进行核实。
6、结束语
在本文中,我解释了如何使用 MicroPython 对 ESP32 进行编程,并将其用作 MIDI 设备。
至于未来的改进方向,有几种:
- 我可以使用 ESP32 的 GPIO 扩展器,这样就能使用更多琴键,并拥有完整的 2-3 个八度音域。
- 我可以深入研究音频合成的数学原理,为音频生成添加更多功能。例如,不同的波形、ADSR(起音、衰减、延音和释放)包络,或者幅度/频率调制。不过,我感觉公众对这些话题的兴趣不大。
原文链接:Making a ‘Stylophone’ Music Synthesizer with Python and MIDI
汇智网翻译整理,转载请标明出处