Prang 重现






4.91/5 (8投票s)
正在重新打磨一个旧的 MIDI 玩具项目,并使其不再像一个玩具。
引言
更新: 我已将 Prang 移植到 Teensy 4.1,并在 GitHub 上发布了该项目。它本身不值得单独写一篇文章,但我想对于关注此项目的人来说,它绝对值得一个更新。方便之处在于该设备内置了 USB 主机功能,除了 Teensy 本身需要焊接的排针外,无需其他焊接,这些排针无论如何都必须焊接。SD 卡读卡器也内置其中,并且速度比那些您经常发现的 1 位 SPI 分离模块快几个数量级。最终结果是一个速度更快、更易于组装的产品,成本也大致相同,甚至如果算上 USB 主机设备和 ESP32S3 开发板的成本,可能还会更便宜。
免责声明:这是一个相当复杂的项目,需要一些焊接。由于我必须修改项目中包含的一个 GPL 许可证库,因此它也以 GPL 许可证发布。
Prang 允许您从 MIDI 文件中提取音轨,并使用您的音乐键盘上的按键来触发它们。剩下的按键则直接从输入传递到输出。好处是您可以使用 MIDI 乐曲的片段来伴奏您的表演,并通过弹奏一个音符来触发它们。
我实在太不会弹奏了,所以我的命不至于因此而危险。不过,希望这能说明 Prang 的作用。
它的工作原理是插在您的键盘 USB 和 PC 之间。它拦截所有的 USB MIDI 流量,并对其进行修改,注入来自 MIDI 文件的 MIDI 数据。
这是核心部分。请注意,这并不是完整的电路。用户界面未在此显示。这里没有显示屏幕、编码器和一些按钮。
我们将要构建它。
引入许可
我想明确一点,只有在实际的 github 分支(或此 zip 文件)中的代码才受 GPL 许可。我通常的许可证方案是 MIT,除了 /lib 目录中的所有依赖项都是 MIT 许可。如果有人想告诉我不能在同一个项目中使用 MIT 和 GPL 许可证,那就带上律师、枪和钱来,我们会解决这个问题。
对于那些不欣赏这种许可方案的各位,我也不喜欢,但由于必须修改 USB 主机库,我的手是被迫的。我还认为,将其设为 GPL3 比仅允许非商业用途(我曾考虑过)要更容易接受。这个项目付出了很多努力,与我的大多数项目不同,我不认为有人可以轻易地将其拿去销售而不做任何回馈是合理的,特别是这是一个端到端的产品而不是一个库。随着我设计出用于制造的 PCB,这一点将变得更加真实。这些 PCB 也将以 GPL3 发布。事实上,我仅在零部件上就花费了 200 多美元来让它工作和测试,以及无数个小时的劳动,所以我有所投入。如果有人想使用它,那太好了,但我希望你们也能有所投入——回馈社区。
硬件
ESP32S3 DevkitC 或类似的开发板,比如 这个。我不知道为什么这些这么贵,因为 MCU 本身在 Mouser 上只卖 1.80 美元。如果您能找到更便宜的,那就去买吧。
USB2.0 主机扩展板,比如 这个。它们是“按设计缺陷”(Broken As Designed)的,必须进行修改,但它们是最好的解决方案。请 参见此处 查看引脚定义,因为它没有标记。
某种 SD 卡读卡器模块,比如 这个。我相信这些是 5v 的,而且是我用的,但说实话,我更倾向于使用 3.3v 而没有稳压器。不过,这些很便宜。
ILI9341 显示屏,就像这个。实际上,我用的是 ILI9342C,因为它是我手头有的。您可能需要更改 ./src/main.cpp 文件中的第 75 行,将 ili9342c
改为 ili9341
或 ili9341v
,具体取决于您的型号。如果显示不正确,请进行更改。请注意,该项目实际上是为一种更便宜但更难获得的 1.18 英寸 240x135 ST7789VW 显示屏(如 TTGO 上的)设计的,但我不想强迫您寻找那样的显示屏,所以我编写的代码使用了更常见的 320x240 ILI9341。您可能已经有了 320x240 的。它不需要触摸屏或其他功能,但如果没有触摸屏,实际上很难找到,除非您愿意等待从中国发货。您可以使用任何 GFX 兼容的 TFT/LCD/OLED 显示屏,只要其尺寸至少为 240x135,但您需要更改一些源代码。
您需要一个旋转编码器,像这些。
您需要两个瞬时按钮,像这些。
您需要一些 USB 线、一把焊锡枪,以及可能一些镊子。
临时改装时间
USB 主机扩展板的设计者在其无限的智慧中,认为用 3.3 伏特为 USB 主机端口供电是可以的。USB 需要大约 5 伏特。有些设备使用 3.3 伏特也能工作,但很多不行。要解决这个问题,我们将切断到 USB“VBUS”线的 3.3v 电源,并自己为其连接一根 5v 线。
首先,找到位于“2k2”字样右侧的那个黑色电阻。您的任务是将其移除。用烙铁将其一个引脚松开,然后用镊子将其提起,使其松动。根据需要对另一侧进行同样操作。尽量不要骂人。
现在,到了难的部分。在焊接排针之前做这个更容易,所以不要犯我犯的错误,否则您可能需要您的另一半来完成,就像我一样。无论如何,在底部,您会看到在排针插孔内部,靠近 USB 端口的地方有一个标有 VBUS 的孔。它将是距离 USB 端口最近的、*不是* 排针插孔的孔。将一根长长的、最好是红色的线焊接到该孔上。另一端连接到 5 伏特。
不用担心这可能会破坏设备用于其他项目。它不会。事实上,它会修复它。这只是将 USB 主机端口恢复到标准。
接线
我假设您对电路有所了解,因此我已在 main.cpp 文件中的一些 #define
中为您提供了整个项目的接线指南。它并不复杂。只是其中有很多部分并不复杂。祝您玩得开心!
可扩展性
我打算制作这个电路板,并能够采购组件来构建这些盒子,用于各种 MIDI 应用,而 Prang 只是其中之一。相同的电路,不同的固件 + 不同颜色的外壳 = 新的设备。我想到了十几款可以使用相同设置制作的 MIDI 盒子。
软件
由于依赖项的数量以及它们全部从 PIO 存储库获取的事实,这基本上需要 Platform IO。据我所知,无法使用 Arduino IDE 构建此项目,因为它不支持 GNU C++17。
它还假定您的所有平台等都已更新,因为它使用的是一个相当新的开发板。
MIDI-OX 也有助于在您的 PC 上路由 MIDI,但您可能已经有了它或您喜欢的其他工具。
它的功能
当您第一次打开这个设备时,如果还没有插入 SD 卡,它会要求您插入 SD 卡。SD 卡上至少应该有一个 MIDI 文件。
如果您有多个 MIDI 文件,在加载完卡根目录下的 MIDI 文件列表后,它会询问您要选择哪个文件。如果只有一个,它会自动选择该文件。
之后,您必须告诉它要映射音轨的基础或根八度。默认情况下,映射从 MIDI C4 开始,这是大多数 MIDI 键盘的左手八度。
一旦您完成了,它会询问您想要什么样的量化级别,如果有的话。默认为 4 拍,这是一个很好的纠错和控制的组合。量化仅影响 MIDI 文件音轨播放的时序,而不影响未映射到 MIDI 文件音轨的按键。
最后,它会询问您是否要保存。如果您选择“是”,下次使用同一张 SD 卡时,它不会再询问这两个问题。如果您想覆盖 SD 卡上的设置,请在打开机器时按住其中一个按钮。设置被写入 SD 卡的根目录,文件名为 prang.csv。
现在您可以开始演奏了。此时如果您转动编码器,则会调整速度乘数。每个音轨都保持自己的速度,并且可能在音轨中发生变化。乘数应用于任何音轨中的任何速度值,因此一个 100bpm 的音轨,速度乘数为 x1.5,将以 150bpm 播放。
按键的映射从基础八度上的 MIDI 文件音轨 0 开始,随着音轨索引的增加向右移动。只有通道 0 被映射。所有其他按键和通道都按原样传递。
工作原理
Prang 作为 USB 主机,连接到其 USB-A 端口的 MIDI 设备。它监听 MIDI 消息,任何它不想拦截的消息都会被传递到它的另一个 USB 端口(在这个设备中是 miniUSB,但如果您想更符合音频标准,可以将 USB-B 端口连接到 GPIO 20 (D+) 和 19 (D-))。
它拦截的任何内容都会被拦截并发送到量化器。
量化器在做什么方面有点复杂。我还要再次指出,量化仅适用于这些被拦截的按键。当没有按键正在播放时,并且按下一个按键,该按键将成为要跟随的“主”按键。其他按键相对于此“主”按键被视为过早或过晚。因此,当按下下一个按键时,它会相对于该按键进行检查,并将偏移量传递给“MIDI 采样器”,我们稍后将介绍它。当按下并松开一个按键时,如果它是主按键,就会寻找一个新的主按键,通过搜索下一个要播放的按键来实现。如果找到一个,那就是新的主按键,否则就没有声音也没有主按键,直到再次按下按键。
采样器的任务是根据请求混合 MIDI 音轨。基本上,您可以在任何给定点开始和停止 MIDI 文件音轨。当您开始一个音轨时,您甚至可以为其设置一个正的或负的“提前量”来进行量化,以便根据需要将其调整为提前或延迟开始。它的概念比量化器简单,但实际实现相当复杂,因为它需要处理多个 MIDI 时钟和游标。
现在,Prang 中的一切都是这样编写的,可以进行协作式多线程处理。您通常调用某项的 update()
来为其分配时间片。但是,为了确保 MIDI 不会中断,Prang 在高优先级线程上运行 MIDI 处理,该线程位于辅助核心上。这之所以有效,是因为我们没有使用蓝牙或 Wi-Fi,否则这些都需要该核心。
为了促进 MIDI 任务和主应用程序任务之间的通信,使用了两个队列:一个队列到 MIDI 线程,一个队列到主线程。这促进了双向通信。这一点很重要,因为用户界面,如旋钮、按钮和屏幕,由主线程控制,而 MIDI 线程处理实际的 MIDI 输入。UI 需要告诉 MIDI 线程速度乘数何时发生变化,而 MIDI 线程需要告诉 UI 何时按键过早、过晚或按时。
应该指出的是,主应用程序代码相当长,但 UI 是顺序的,并且在回放最终开始之前,设置基本上是从头到尾运行的。因此,尽管它相对笨拙,但并非无法导航。
编写这个混乱的程序
有关项目先前开发状态的更详细视图,请参阅 这篇文章。这篇文章也涵盖了 MIDI 采样器和 setup()
,所以我们在这里将主要跳过这些。
Prang 使用我的 SFX 库来完成大部分 MIDI 处理。我没有将输入处理通过 SFX 分离出来,因为我在那个级别上不需要太多抽象,并且我想保持紧凑。但是,位于应用程序核心的 midi_sampler
大量使用了它。
它使用我的 GFX 库来处理图形用户界面组件。
它使用我的 FreeRTOS 线程包来处理线程和消息队列。
而且,几乎所有的库都是我的代码,除了实际的 MIDI 驱动程序(输入和输出)以及编码器库。/lib 下的所有代码都是第三方代码,即使经过我的修改。否则,所有代码都是我的代码,即使是通过 platformio.ini lib_deps
引入的。
所有这些运行都没有使用任何 PSRAM,并且它使用 SPIFFS 上的 TrueType,这需要在绘制字体之前将其加载到 RAM 中,否则会让您想要放弃。为了使其正常工作,我们会根据需要加载和卸载字体。
我们有一个较小的 TrueType 字体嵌入为一个头文件(telegrama.hpp),它用作备用和系统字体。它永远不会被卸载,并且不需要 SRAM,因为它在闪存中。
还有一个 MIDI.jpg 被显示,同样来自 SPIFFS。
首次运行时,请记住使用 **Project Tasks**|**Upload Filesystem Image**。
我认为最好先介绍 main.cpp 中的 midi_task()
,所以我们先从那里开始。这个例程是处理所有 MIDI 输入和输出并驱动 MIDI 采样器的高优先级线程。
void midi_task(void* state) {
uint8_t buffer[MIDI_EVENT_PACKET_SIZE];
uint16_t rcvd;
int i, j;
while (true) {
queue_info qi;
// handle incoming queue messages
if (queue_to_thread.receive(&qi, false)) {
switch (qi.cmd) {
case 1:
sampler.tempo_multiplier(qi.value);
break;
default:
break;
}
}
Usb.Task();
if (midi_in) {
if (midi_in.RecvData(&rcvd, buffer) == 0) {
const uint8_t* p = buffer + 1;
last_status = *(p++);
int base_note = base_octave * 12;
bool note_on = false;
int note;
int vel;
unsigned long long next_off_elapsed = 0;
int next_off_track = -1;
queue_info qi;
int s = last_status;
if (s < 0xF0) {
s &= 0xF0;
}
switch ((midi_message_type)s) {
case midi_message_type::note_on:
note_on = true;
case midi_message_type::note_off:
note = *(p++);
vel = *(p++);
// is the note within our captured notes?
if ((last_status & 0x0F) == 0 &&
note >= base_note &&
note < base_note + sampler.tracks_count()) {
if (note_on && vel > 0) {
quantizer.start(note - base_note);
qi.cmd = 1;
qi.value = (int)quantizer.last_timing();
queue_to_main.send(qi, false);
} else {
quantizer.stop(note - base_note);
}
} else {
// just forward it
tud_midi_stream_write(0, buffer + 1, 3);
}
break;
case midi_message_type::polyphonic_pressure:
case midi_message_type::control_change:
case midi_message_type::pitch_wheel_change:
case midi_message_type::song_position:
// length 3 message - forward it
tud_midi_stream_write(0, buffer + 1, 3);
break;
case midi_message_type::program_change:
case midi_message_type::channel_pressure:
case midi_message_type::song_select:
// length 2 message - forward it
tud_midi_stream_write(0, buffer + 1, 2);
break;
case midi_message_type::system_exclusive:
for (j = 2; j < sizeof(buffer); ++j) {
if (buffer[j] == 0xF7) {
break;
}
}
// sysex message - forward it
tud_midi_stream_write(0, buffer + 1, j);
break;
case midi_message_type::reset:
case midi_message_type::end_system_exclusive:
case midi_message_type::active_sensing:
case midi_message_type::start_playback:
case midi_message_type::stop_playback:
case midi_message_type::tune_request:
case midi_message_type::timing_clock:
// length 1 message - forward it
tud_midi_stream_write(0, buffer + 1, 1);
break;
}
}
}
// send any pending sampler MIDI data
sampler.update();
// release a timeslice to the OS
vTaskDelay(1);
}
}
考虑到我们必须解析二进制流,这段代码实际上并不复杂。
在我们进行任何 MIDI 处理之前,我们会检查队列中是否有等待的消息。如果有,我们就处理它,在这种情况下是改变速度乘数。
之后,我们处理 MIDI 输入。输入基本上是 MIDI 线协议,前面有一个我不知道其目的的字节。奇怪的是,MIDI 消息在 USB 上总是占用 64 字节,即使实际消息通常在 1 到 3 字节之间。据我所知,这些消息中总是包含 MIDI 状态字节。否则,就是标准的 MIDI。除了 note on 和 note off 消息之外的所有消息都会被直接传递。我们之所以需要检查它们,仅仅是为了知道它们有多少字节。只有在通道零上处于活动映射范围内的 note on 和 note off 消息才会被拦截。当我们找到一个时,我们就将其发送给量化器,量化器本身连接到采样器。我们做的另一件事是检查刚播放的音符的时序,并将一个指示器发送回主线程,以便它可以在屏幕上显示。
下一个主要例程是 scan_file()
,它读取整个 MIDI 文件,查看每个音轨并搜索速度变化消息。结果可以是零、一个或多个。零表示默认速度为 120bpm,这是 MIDI 标准。一个表示音轨以指定速度播放。如果速度超过一个,则表示为变化的。
sfx_result scan_file(File& file, midi_file_info* out_info) {
midi_file mf;
file_stream fs(file);
sfx_result r = midi_file::read(fs, &mf);
if (r != sfx_result::success) {
return r;
}
out_info->tracks = (int)mf.tracks_size;
int32_t file_mt = 500000;
for (size_t i = 0; i < mf.tracks_size; ++i) {
if (mf.tracks[i].offset != fs.seek(mf.tracks[i].offset)) {
return sfx_result::end_of_stream;
}
bool found_tempo = false;
int32_t mt = 500000;
midi_event_ex me;
me.absolute = 0;
me.delta = 0;
while (fs.seek(0, seek_origin::current) < mf.tracks[i].size) {
size_t sz = midi_stream::decode_event(true, fs, &me);
if (sz == 0) {
return sfx_result::unknown_error;
}
if (me.message.status == 0xFF && me.message.meta.type == 0x51) {
int32_t mt2 = (me.message.meta.data[0] << 16) |
(me.message.meta.data[1] << 8) |
me.message.meta.data[2];
if (!found_tempo) {
found_tempo = true;
mt = mt2;
file_mt = mt;
} else {
if (mt != file_mt) {
mt = 0;
file_mt = 0;
break;
}
if (mt != mt2) {
mt = 0;
file_mt = 0;
break;
}
}
}
}
}
out_info->microtempo = file_mt;
out_info->type = mf.type;
return sfx_result::success;
}
文件其余大部分是 setup()
和 UI 支持代码。它非常长、无聊且丑陋,所以我在这里不展示。有关其概述,请参阅我之前链接的 Prang 文章,它现在几乎相同,但增加了更多的屏幕和功能,如保存。
现在,应用程序的主要部分只剩下 loop()
了。
void loop() {
queue_info qi;
if (queue_to_main.receive(&qi, false)) {
if (qi.cmd == 1) {
off_ts = millis() + 1000;
auto px = color_t::white;
switch ((int)qi.value) {
case 0:
px = color_t::green;
break;
case -1:
px = color_t::blue;
break;
case 1:
px = color_t::red;
break;
default:
break;
}
draw::filled_ellipse(lcd, rect16(point16(20, 20), 10), px);
}
}
if (off_ts != 0 && millis() >= off_ts) {
off_ts = 0;
draw::filled_ellipse(lcd, rect16(point16(20, 20), 10), color_t::white);
}
bool inc;
int64_t ec = (encoder.getCount() / 4);
if (encoder_old_count != ec) {
inc = (encoder_old_count > ec);
encoder_old_count = ec;
if (inc) {
if (tempo_multiplier < 4.99) {
tempo_multiplier += .01;
update_tempo_mult();
}
} else {
if (tempo_multiplier > .01) {
tempo_multiplier -= .01;
update_tempo_mult();
}
}
}
}
loop()
只做几件事。
首先,它监听消息队列中的传入消息。这些消息指示音符被按下以及其时序。我们通过显示一个圆圈来指示,圆圈的颜色分别为红色、绿色或蓝色,分别表示音符过晚、过早或按时。
接下来,它检查自上次显示音符(来自上面)以来是否已经过了一秒钟。如果过去了一秒,它会在之前的圆圈上绘制一个白色的圆圈,将其擦除。
最后,它检查编码器旋钮是否已更改,如果已更改,则更新速度乘数。
自上次讨论 Prang 以来,它的一项不同之处在于改进了量化功能,该功能已被重构为 midi_quantizer
。该类接收各个 MIDI 采样器音轨的 start()
和 stop()
命令,并为每个 start()
计算适当的量化提前量(负或正)。该类的核心是那个方法。
sfx_result midi_quantizer::start(size_t index) {
if(m_sampler==nullptr ||
index<0||
index>=m_sampler->tracks_count()) {
return sfx_result::invalid_argument;
}
m_last_key_ticks = m_sampler->elapsed(index);
if(!m_quantize_beats || m_follow_key==-1) {
m_sampler->start(index);
m_key_advance[index]=0;
m_follow_key = index;
m_last_timing = midi_quantizer_timing::exact;
return sfx_result::success;
}
unsigned long long smp_elapsed;
unsigned long long adv=0;
int tb = m_sampler->timebase(m_follow_key)
* m_quantize_beats;
smp_elapsed=m_sampler->elapsed(m_follow_key)
- m_key_advance[m_follow_key];
adv= smp_elapsed % tb;
unsigned long long adv2=adv-tb;
if(adv>-adv2) {
adv=adv2;
m_last_timing = midi_quantizer_timing::early;
} else if(adv!=0) {
m_last_timing = midi_quantizer_timing::late;
}
sfx_result r = m_sampler->start(index,adv);
if(r!=sfx_result::success) {
return r;
}
m_key_advance[index]=adv;
return sfx_result::success;
}
在这里,我们跟踪最近一次按下按键的最后一个经过的滴答计数。然后,如果尚未进行量化,我们只需启动音轨,设置跟随按键和上次记录的时间,然后返回。否则,我们使用当前的跟随按键和该按键经过的时间来计算下一个或上一个量化标记的位置。无论哪个更接近,我们都用它来决定我们的提前量,然后将其传递给采样器并更新我们存储的按键提前量。
结论
通过通用的硬件设计,可以实现无限的 MIDI 处理盒,以及像 SFX 这样的功能齐全的 MIDI 库,您可以随心所欲地设计新设备,或者简单地修改 Prang 以满足您的确切需求。祝您构建愉快!
历史
- 2022 年 8 月 8 日 - 首次提交