Prang:ESP32S3 上的 MIDI“乐谱采样器”





5.00/5 (2投票s)
从 Type 2 MIDI 文件触发音轨,添加到您的录音或表演中。
引言
我最初将 Prang 做成了 FL Studio 插件,但存在一些限制,而且我真的很想要一个小型硬件设备来完成这项原本需要 PC 才能完成的工作。
展示它的功能比讲述它更容易,所以我就这么做。请原谅我糟糕的节奏。这些微动开关对手指来说很难受。操作非常僵硬,Prang 的最终版本不会使用它们。取而代之的是,它将从另一个 MIDI 设备获取输入。由于我还在等待该设备的零件,因此尚未实现。
我在这里所做的是,当我按下相应的按钮时,我会播放单独的音轨。prang.mid 文件有 4 个音轨,我基本上可以根据需要触发它们来组成简单的小节拍。
此版本使用硬接线到设备的按钮,但最终版本将带有 MIDI 插孔,并接受来自其他 MIDI 设备(如键盘)的信号。这样,每个按键都可以触发不同的音轨,而且比廉价的微动开关更舒适、反应更灵敏!
此项目目前需要 Espressif 的 ESP32-S3 DevkitC,但通过一些修改可以在 ESP32-S2 上工作。它目前使用第二个可编程 USB 端口来输出 MIDI。我使用 MIDI-OX 来监控 PC 上的输入 MIDI 并将其路由到我的声卡。
我们将使用我的预发布 SFX 库来促进 MIDI 功能,并使用我的 GFX 库来处理显示。
此项目还在 /lib 文件夹下使用了 ESPTinyUSB 和 ESP32Encoder。这些部分不是我写的。我将它们与项目一起分发,因为据我所知,它们不在 Platform IO 存储库中——至少不是我需要的版本。我编写了一个小的适配器来将我的 SFX midi_output
接口与 ESPTinyUSB 适配,这就是我使用它的范围。它负责 USB MIDI 输出。另一个库负责编码器旋钮。
硬件组件
您需要一个 ESP32S3 DevkitC 板或类似的板。您需要一个 ILI9341 显示屏,尽管通过少量修改代码,您可以将其替换为其他 320x240 单位,如 ST7789。您需要一个编码器、一个 SD 读卡器和 4 个微动开关。Wiring.txt 文件包含详细信息。
背景
它的工作方式有点像我最近写过的 MIDI 流媒体,但它足够不同,值得专门写一篇文章。该设备主要处理 MIDI 类型 2 文件。目前,它将所有 MIDI 文件都视为类型 2,但这将在最终版本之前改变。
对于 MIDI 类型 2,每个音轨都独立于文件中的其他音轨,并且可以拥有自己的速度和音色设置等。Prang 将文件中的每个音轨视为其自己的可独立触发的乐谱。目前只有 4 个按钮,但您可以连接更多。正如我所说,最终版本将接受 MIDI 输入,因此理论上可以支持 128 个音轨,当然,内存允许的话。
请参阅 wiring.txt 文件了解如何连接它的说明。这相当简单,但无论它们有多简单,仍然有很多连接要做。
如果您深入研究项目,您会注意到 platformio.ini 中有一些混乱的地方。混乱是为了将 S3 的 Arduino 支持塞进 Platform IO。只需在它上面挥舞一只死鸡,一切都会没事的。
这里的技巧是在按下相应按钮时触发音轨,并在释放按钮时停止它。
这里的一个棘手之处在于,MIDI 类型 2 规范建议每个音轨都可以包含自己的速度。我们为每个音轨使用一个 midi_clock
以方便实现这一点。MIDI 时钟基本上需要一个每四分音符的节拍数的时间基准,以及一个每分钟节拍数的速度,然后计算出“MIDI 节拍”的微秒持续时间。然后,它使用该持续时间在每个 MIDI 节拍间隔调用一个回调函数。时钟是协作多线程的,这意味着它必须通过其 update()
方法进行驱动才能工作。
另一个复杂之处在于停止播放音轨。MIDI 通过 note on 和 note off 消息发出信号。因此,如果您在发送一个与已发送的 note on 对应的 note off 消息之前停止播放,该音符将保持悬空/无限播放。这显然是不受欢迎的。一种选择是发送 MIDI kill,但这会停止所有播放的音符,我们不想这样做。相反,我们维护一个 128 位缓冲区,每条音符都有一个位,然后是 16 个这样的缓冲区,每个通道一个。我们处理 MIDI 消息,并使用上述数组来存储音符何时播放或何时停止。当我们停止一个通道时,我们使用该数据来确定哪些音符正在播放,然后为每个音符发送 note off。
由于这是时间敏感的,并且音轨的长度可能有限,因此我们将每个音轨加载到内存中。我们存储当前输入位置、当前事件、时钟、缓冲区、输出(我们可能最终支持多个)以及每个音轨的一些其他信息。
我们做的另一件事是大量地处理字体。特别是,我们在到处使用的红色的大而杂乱的字体仅在需要时加载,然后将其卸载以节省 RAM。另一方面,“系统字体”更易读,但不那么花哨,它被嵌入为一个头文件,同样是为了节省 RAM。我可以将两者都嵌入为头文件,但我更喜欢将我的资产保留在 SPIFFS 上,只要这在实际操作中是可行的,以便缩短上传时间。我不想让系统字体“卸载”,因为你永远不知道什么时候需要在整个应用程序中显示一些文本。保留系统字体是有意义的。
所有这些处理都是必要的,因为我的设计可以在没有 PSRAM 的情况下工作,我之所以这样做是因为找到一个可以实际使用 PSRAM 的 S3 板的发布候选版本并不容易。我最终得到了两个 Beta 板,并且暂时放弃了,因为很难确定哪个型号/修订版处于哪个发布阶段。
总之,代码的核心是 midi_samper
类,它完成了我们上面提到的大部分工作。我们将花大部分时间来讲解它,即使一旦您理解了驱动它的各个组件,它实际上并没有那么复杂。
midi_sampler
如承诺的,我们将讲解 midi_sampler
。它的工作是加载 MIDI 文件,然后在需要时循环启动音轨,每个音轨都有自己的速度和在表演中的起始位置。
我们需要做的第一件事是提供读取 MIDI 文件的功能。我们可以自己完成,或者我们可以使用 SFX 的 midi_file
类来完成大部分工作。我们将这样做。唯一的缺点是它需要一个可搜索的流才能以这种方式完成,但这没问题,因为我们不是从互联网获取 MIDI 文件。
一旦我们有了偏移量,然后对于每个音轨,我们分配足够的内存来保存每个音轨的音轨信息,然后对于每个音轨,我们再次分配更多内存,并将音轨复制到其中。这可能涉及几次小分配,这可能不是理想的,具体取决于您的情况,但我们使用它的方式是,它是应用程序的核心,所以我们将优先考虑它。之后没有其他重要的事情应该加载,所以堆碎片不应该成为主要问题。即使会,在分配方面,它的行为仍然比 STL 好很多。我们还允许您传入一个自定义分配器,这样您就可以使用自己的堆,或者可能使用 PSRAM。
总之,在执行此操作时,我们必须初始化所有内容,启动时钟,并为其设置回调。尽管使用了几个时钟,但我们只需要一个回调来处理所有时钟。
sfx_result midi_sampler::read(stream* in,
midi_sampler* out_sampler,
void*(allocator)(size_t),
void(deallocator)(void*)) {
if(in==nullptr ||
out_sampler==nullptr||
allocator==nullptr||
deallocator==nullptr) {
return sfx_result::invalid_argument;
}
if(!in->caps().read || !in->caps().seek) {
return sfx_result::io_error;
}
midi_file file;
sfx_result res = midi_file::read(in,&file);
if(res!=sfx_result::success) {
return res;
}
track *tracks =
(track*)allocator(sizeof(track)*file.tracks_size);
if(tracks==nullptr) {
return sfx_result::out_of_memory;
}
for(int i = 0;i<file.tracks_size;++i) {
track& t = tracks[i];
t.buffer = nullptr;
}
for(int i = 0;i<file.tracks_size;++i) {
track& t = tracks[i];
midi_track& mt = file.tracks[i];
t.buffer = (uint8_t*)allocator(mt.size);
if(t.buffer==nullptr) {
res= sfx_result::out_of_memory;
goto free_all;
}
if(mt.offset!=in->seek(mt.offset) ||
mt.size!=in->read(t.buffer,mt.size)) {
res = sfx_result::io_error;
goto free_all;
}
t.tempo_multiplier = 1.0;
t.base_microtempo = 500000;
t.clock.timebase(file.timebase);
t.clock.microtempo(500000);
t.clock.tick_callback(callback,&t);
t.buffer_size = mt.size;
t.buffer_position = 0;
t.event.message.status = 0;
t.event.absolute = 0;
t.output = nullptr;
}
out_sampler->m_allocator = allocator;
out_sampler->m_deallocator = deallocator;
out_sampler->m_tracks = tracks;
out_sampler->m_tracks_size = file.tracks_size;
return sfx_result::success;
free_all:
if(tracks!=nullptr) {
for(size_t i=0;i<file.tracks_size;++i) {
track& t = tracks[i];
if(t.buffer!=nullptr) {
deallocator(t.buffer);
}
}
deallocator(tracks);
}
return res;
}
最后,如果一切顺利,我们将刚刚填写的内存分配给传入的 midi_sampler
。该例程的最后一部分是错误处理,它只是在返回错误之前释放所有分配。
我们填写了一些音轨结构但还没有讨论它们,所以让我们简要过渡到
struct track {
sfx::midi_clock clock;
sfx::midi_event_ex event;
note_tracker tracker;
int32_t base_microtempo;
float tempo_multiplier;
uint8_t* buffer;
size_t buffer_size;
size_t buffer_position;
sfx::midi_output* output;
};
正如我提到的,我们为每个音轨维护一个 midi_clock
。下一个成员是最后一个从流中提取的 event
,以及它在 MIDI 节拍中的绝对位置。接下来是 note_tracker
,它负责跟踪每个按下音符,以便在需要停止音轨播放时发送 note off。base_microtempo
是当前从音轨报告的微秒速度,在应用速度乘数之前。tempo_multiplier
表示实际播放速度与基本微秒速度的比较。buffer
和 buffer_size
字段指示我们音轨数据的缓冲区,而 buffer_position
指示音轨数据中的输入位置。最后,output
指示将消息发送到的 MIDI 设备。
让我们来介绍一些非琐碎的内容,比如 start()
sfx_result midi_sampler::start(size_t index,
unsigned long long advance) {
if(0>index || index>=m_tracks_size) {
return sfx_result::invalid_argument;
}
track& t = m_tracks[index];
stop(index);
if(advance) {
const_buffer_stream cbs(t.buffer,t.buffer_size);
t.clock.elapsed(advance);
t.event.message.status = 0;
t.event.absolute = 0;
while(t.event.absolute<advance) {
size_t sz = midi_stream::decode_event(true,&cbs,&t.event);
if(sz==0) {
break;
}
t.buffer_position+=sz;
if(t.event.message.status==0xFF &&
t.event.message.meta.type==0x51) {
int32_t mt = (t.event.message.meta.data[0] << 16) |
(t.event.message.meta.data[1] << 8) |
t.event.message.meta.data[2];
// update the clock microtempo
t.base_microtempo = mt;
t.clock.microtempo(mt/t.tempo_multiplier);
} else if(t.output!=nullptr) {
switch(t.event.message.type()) {
case midi_message_type::program_change:
case midi_message_type::control_change:
case midi_message_type::system_exclusive:
case midi_message_type::end_system_exclusive:
t.output->send(t.event.message);
break;
default:
break;
}
}
}
}
t.clock.start();
return sfx_result::success;
}
如果不是为了“advance”部分,这个方法将是微不足道的。Advance 帮助我们进行量化。它的作用是允许您将播放从音轨中指定的节拍数开始。我们通过手动设置时钟已过去节拍数来做到这一点,然后读取所有在 advance 点之前的事件。我们丢弃大部分事件,除了速度更改消息、程序更改、控制更改和系统独占消息。我们发送或处理这些消息,以保持我们的循环一致。如果我们不处理这些,声音或播放速度可能与我们预期的有显著差异。
stop()
方法更简单
sfx_result midi_sampler::stop(size_t index) {
if(0>index || index>=m_tracks_size) {
return sfx_result::invalid_argument;
}
track& t = m_tracks[index];
t.clock.stop();
t.buffer_position = 0;
t.event.absolute = 0;
t.event.delta = 0;
t.event.message.~midi_message();
t.event.message.status = 0;
t.base_microtempo = 500000;
t.clock.microtempo(t.base_microtempo/t.tempo_multiplier);
if(t.output!=nullptr) {
t.tracker.send_off(*t.output);
}
return sfx_result::success;
}
我们在这里所做的就是停止时钟,将所有内容设置回初始状态,然后向任何指定的输出发送 note off。
现在让我们来看看所有内容都在 callback()
中处理的魔法。我们在这里所做的是,如果当前事件的位置是当前位置或更早,就播放该事件,并继续获取事件,直到情况不再如此。当我们获得一个事件时,如果它是速度更改,我们就更新速度,否则,如果它不是空消息(状态为零),我们就将其传递给 note tracker,然后传递给 output。然后,我们读取下一个事件,如果流中还有更多可用事件。如果没有,我们就将音轨重置回初始状态并重新启动时钟,循环播放
void midi_sampler::callback(uint32_t pending,
unsigned long long elapsed,
void* pstate) {
track *t = (track*)pstate;
while(t->event.absolute<=elapsed) {
if (t->event.message.type() ==
midi_message_type::meta_event) {
// if it's a tempo event update the clock tempo
if(t->event.message.meta.type == 0x51) {
int32_t mt = (t->event.message.meta.data[0] << 16) |
(t->event.message.meta.data[1] << 8) |
t->event.message.meta.data[2];
// update the clock microtempo
t->base_microtempo = mt;
t->clock.microtempo(mt/t->tempo_multiplier);
}
}
else if(t->event.message.status!=0) {
t->tracker.process(t->event.message);
if(t->output!=nullptr) {
t->output->send(t->event.message);
}
}
bool restarted = false;
if(t->buffer_position>=t->buffer_size) {
t->buffer_position = 0;
t->event.absolute = 0;
t->event.delta = 0;
t->event.message.~midi_message();
t->event.message.status=0;
t->clock.stop();
t->clock.microtempo(t->base_microtempo/t->tempo_multiplier);
t->clock.start();
restarted = true;
if(t->output!=nullptr) {
t->tracker.send_off(*t->output);
}
}
const_buffer_stream cbs(t->buffer,t->buffer_size);
cbs.seek(t->buffer_position);
size_t sz = midi_stream::decode_event(true,(stream*)&cbs,&t->event);
t->buffer_position+=sz;
if(sz==0) {
t->clock.stop();
}
if(restarted) {
break;
}
}
}
note_tracker
让我们来谈谈 note_tracker
。它没什么大不了的。我们保留两个 64 位无符号整数来容纳 128 位——每条音符 1 位。我们保留 16 个这样的整数——每个 MIDI 通道一个。我们处理传入的消息,通过查找 note off 和 note on,然后根据需要设置或清除该通道上的位。当我们收到停止请求时,我们会遍历所有位,并为每个当前活动的音符发送一个 note off。
#include "note_tracker.hpp"
#include <string.h>
note_tracker::note_tracker() {
memset(m_notes,0,sizeof(m_notes));
}
void note_tracker::process(const sfx::midi_message& message) {
sfx::midi_message_type t = message.type();
if(t==sfx::midi_message_type::note_off ||
(t==sfx::midi_message_type::note_on &&
message.msb()==0)) {
uint8_t c = message.channel();
uint8_t n = message.lsb();
if(n<64) {
const uint64_t mask = uint64_t(~(1<<n));
m_notes[c].low&=mask;
} else {
const uint64_t mask = uint64_t(~(1<<(n-64)));
m_notes[c].high&=mask;
}
} else if(t==sfx::midi_message_type::note_on) {
uint8_t c = message.channel();
uint8_t n = message.lsb();
if(n<64) {
const uint64_t set = uint64_t(1<<n);
m_notes[c].low|=set;
} else {
const uint64_t set = uint64_t(1<<(n-64));
m_notes[c].high|=set;
}
}
}
void note_tracker::send_off(sfx::midi_output& output) {
for(int i = 0;i<16;++i) {
for(int j=0;j<64;++j) {
const uint64_t mask = uint64_t(1<<j);
if(m_notes[i].low&mask) {
sfx::midi_message msg;
msg.status =
uint8_t(uint8_t(sfx::midi_message_type::note_off)|uint8_t(i));
msg.lsb(j);
msg.msb(0);
output.send(msg);
}
}
for(int j=0;j<64;++j) {
const uint64_t mask = uint64_t(1<<j);
if(m_notes[i].high&mask) {
sfx::midi_message msg;
msg.status =
uint8_t(uint8_t(sfx::midi_message_type::note_off)|uint8_t(i));
msg.lsb(j+64);
msg.msb(0);
output.send(msg);
}
}
}
memset(m_notes,0,sizeof(m_notes));
}
midi_esptinyusb.cpp
这基本上是一个适配器,用于将 SFX midi_output
类的调用适配到 ESPTinyUSB
库,以便通过 USB 传输 MIDI。它所做的就是将消息分解,并将其转换为字节以便在电线上发送。
MIDIusb midi_esptinyusb_midi;
bool midi_esptinyusb_initialized = false;
bool midi_esptinyusb::initialized() const {
return midi_esptinyusb_initialized;
}
sfx::sfx_result midi_esptinyusb::initialize(const char* device_name) {
if(!midi_esptinyusb_initialized) {
midi_esptinyusb_initialized=true;
#ifdef CONFIG_IDF_TARGET_ESP32S3
midi_esptinyusb_midi.setBaseEP(3);
#endif
char buf[256];
strncpy(buf,device_name==nullptr?"SFX MIDI Out":device_name,255);
midi_esptinyusb_midi.begin(buf);
#ifdef CONFIG_IDF_TARGET_ESP32S3
midi_esptinyusb_midi.setBaseEP(3);
#endif
delay(1000);
midi_esptinyusb_initialized = true;
}
return sfx::sfx_result::success;
}
sfx::sfx_result midi_esptinyusb::send(const sfx::midi_message& message) {
sfx::sfx_result rr = initialize();
if(rr!=sfx::sfx_result::success) {
return rr;
}
uint8_t buf[3];
if(message.type()==sfx::midi_message_type::meta_event &&
(message.meta.type!=0 || message.meta.data!=nullptr)) {
return sfx::sfx_result::success;
}
if (message.type()==sfx::midi_message_type::system_exclusive) {
// send a sysex message
uint8_t* p = (uint8_t*)malloc(message.sysex.size + 1);
if (p != nullptr) {
*p = message.status;
if(message.sysex.size) {
memcpy(p + 1, message.sysex.data, message.sysex.size);
}
tud_midi_stream_write(0, p, message.sysex.size + 1);
// write the end sysex
*p=0xF7;
tud_midi_stream_write(0, p, 1);
free(p);
}
} else {
// send a regular message
// build a buffer and send it using raw midi
buf[0] = message.status;
switch (message.wire_size()) {
case 1:
tud_midi_stream_write(0, buf, 1);
break;
case 2:
buf[1] = message.value8;
tud_midi_stream_write(0, buf, 2);
break;
case 3:
buf[1] = message.lsb();
buf[2] = message.msb();
tud_midi_stream_write(0, buf, 3);
break;
default:
break;
}
}
return sfx::sfx_result::success;
}
这只是处理通过 USB 激活 MIDI 的细节,然后如我所说,将消息打包以进行电线传输。
main.cpp
这个文件是我们把所有东西联系起来的地方。它有点混乱,因为使它干净所需的不间断抽象并不能在最后阶段带来回报。我的意思是,你可以像上面一样抽象你的大部分应用程序,但你的main.cpp——你的“胶水”——永远是胶水。你可以把它变成漂亮的胶水,但它仍然是胶水。在我们的例子中,我们放弃了任何可能使main.cpp更加整洁的高级 UI 小部件,但这需要比它提供的秩序更多的努力。这是值得的。
main 的一个好处是,它基本上是顺序的。随着应用程序的向前流动,代码只是从上到下移动,而不是四处跳转。没有像我许多应用程序中的中央调度程序——与其说是多个屏幕,不如说是基本上只有一个屏幕,尽管它会定期更改。
我们还劫持了 setup()
而不是使用 loop()
。原因之一是 setup()
任务比 loop()
任务分配到更多的堆栈空间。另一个原因与此实例中 Arduino 框架的设计,坦率地说,很差有关。将 loop()
分开作为一个单独的例程,强制任何共享数据都作为全局变量来保存。全局变量没问题,我在这里不介意使用一些全局变量,但它大大增加了初始化的复杂性,特别是因为初始化这些全局变量的许多调用必须在 setup()
开始后才能进行!这就是为什么 Arduino 类几乎都有一个 begin()
方法。还有其他方法可以解决这个问题,例如创建一个结构体来保存共享数据,在 setup 中使用 malloc()
进行初始化,并将其指针作为全局变量保存,但这并没有解决堆栈空间问题。
我们在顶部有一些样板定义,如果您使用过 GFX,其中一些会很熟悉。这主要是为了建立我们的 ILI9341 和 SPI 总线连接。
让我们从后面更有趣的内容开始。首先,我们的全局变量
lcd_t lcd;
ESP32Encoder encoder;
int64_t encoder_old_count;
float tempo_multiplier;
midi_sampler sampler;
uint8_t* prang_font_buffer;
size_t prang_font_buffer_size;
midi_esptinyusb out;
int switches[4];
int follow_track = -1;
RingbufHandle_t signal_queue;
TaskHandle_t display_task;
第一个是我们的显示屏。我们在此绘制。
第二个是我们的编码器驱动程序。之后,我们保留编码器驱动程序报告的旧计数,以便检测何时发生变化。
我们还保留 tempo_multiplier
,因为我们需要一个始终有效的指针。
接下来是我们的 sampler
。这基本上是应用程序逻辑的核心。
之后,我们有几个成员变量,用于保存我们用于红色大而杂乱字体的内存缓冲区。此字体在需要时从 SPIFFS 加载到内存中,因此此缓冲区在应用程序生命周期中可能会被分配和释放多次。
之后,我们有了 midi_esptinyusb
MIDI 输出驱动程序。MIDI 数据就是从这里发送的。
现在我们有一个用于开关的数组。这些是我们用于控制音轨播放时间及其的按钮。此数组保存了旧/当前开关值,以便我们检测到变化。
之后是 follow_track
。这有点奇怪。基本上它是为了量化。我们需要一个参考音轨来对齐其他音轨的节拍,因为我们量化到最近的节拍。如果没有音轨在播放,此值为 -1
,播放的第一个音轨将成为其他音轨同步的参考音轨。当该音轨停止播放时,音轨将被切换到其他正在播放的音轨之一。这样,我们总会有一个可以同步节拍的节拍参考,除非没有任何东西在播放,在这种情况下,播放的第一个音轨将成为其他音轨跟随的新参考音轨。
之后,我们保留一个环形缓冲区 signal_queue
。这是一个线程安全的即时消息传递方案,用于更新显示屏,因为我们在第二个核心上执行此操作,以避免中断 MIDI 播放。
display_task
是实际用于按上述方式更新显示的任务。
全局变量之后,我们有一个 draw_error()
辅助方法,它只是在可用时使用大而杂乱的字体在屏幕上绘制错误消息,否则使用始终可用的系统字体。
现在开始 setup()
!
首先,我们初始化我们的按钮
pinMode(P_SW1,INPUT_PULLDOWN);
pinMode(P_SW2,INPUT_PULLDOWN);
pinMode(P_SW3,INPUT_PULLDOWN);
pinMode(P_SW4,INPUT_PULLDOWN);
memset(switches,0,sizeof(switches));
接下来,我们初始化编码器
ESP32Encoder::useInternalWeakPullResistors=UP;
encoder_old_count = 0;
encoder.attachFullQuad(ENC_CLK,ENC_DT);
现在,我们初始化我们的串行接口、显示屏、SPIFFS 和 SD 卡
Serial.begin(115200);
SPIFFS.begin();
// ensure the SPI bus is initialized
lcd.initialize();
SD.begin(SD_CS,spi_container<0>::instance());
由于 SD 读卡器与显示屏使用相同的总线,因此我们首先初始化显示屏,以确保 SPI 已初始化到正确的引脚。然后,我们将 SPI 主机零的 SPI 实例——与显示屏使用的相同实例——传递给 SD 卡以进行初始化。
将我们的速度乘数设置为 1.0
tempo_multiplier = 1.0;
创建环形缓冲区
signal_queue = xRingbufferCreate(sizeof(float) *
8 + (sizeof(float) - 1),
RINGBUF_TYPE_NOSPLIT);
if(signal_queue==nullptr) {
Serial.println("Unable to create signal queue");
while(true);
}
这是我们用于允许主任务/线程更新显示任务/线程的消息系统。大小计算是我猜测的,因为文档不清楚需要多少额外空间才能避免分割。然而,这个公式过去对我很有效。
接下来,我们使用一个“扁平”的 lambda 创建显示任务。在 lambda 中,我们只是尝试从队列中拉取一条消息,如果收到了消息,我们就更新速度显示。
if(pdPASS!=xTaskCreatePinnedToCore([](void* state){
float scale = Telegrama_otf.scale(20);
while(true) {
size_t fs=sizeof(float);
float* pf=(float*)xRingbufferReceive(signal_queue,&fs,0);
if(nullptr!=pf) {
float f = *pf;
vRingbufferReturnItem(signal_queue,pf);
char text[64];
sprintf(text,"tempo x%0.1f",f);
ssize16 sz = Telegrama_otf.measure_text(ssize16::max(),
spoint16::zero(),text,scale);
srect16 rect = sz.bounds().center_horizontal
((srect16)lcd.bounds()).offset(0,3);
draw::filled_rectangle(lcd,srect16
(0,rect.y1,lcd.dimensions().width-1,rect.y2),color_t::white);
draw::text(lcd,rect,spoint16::zero(),text,Telegrama_otf,
scale,color_t::black,color_t::white,false);
}
}
},"Display Task",4000,nullptr,0,&display_task,1-xPortGetCoreID())) {
Serial.println("Unable to create display task");
while(true);
}
您可能已经注意到速度是一个乘数而不是直接的每分钟节拍数。原因是每个音轨都可以有自己的速度,所以我们不一定有一个可以显示的单一速度,但我们确实将相同的乘数应用于每个音轨,至少在当前版本中如此,这样我们就可以显示该数字。当我创建这些 MIDI 文件时,我从文件中删除了任何速度消息,这样它们的基础速度都是 120.0 BPM——MIDI 的默认速度。然后您可以使用编码器进行调整。
之后,我们有了主应用程序逻辑,从启动屏幕开始。我们用一个 restart
标签标记它,以便在需要时(例如出现错误时)可以跳转回开头。
在标签之后,我们通过从 SPIFFS 加载 MIDI JPG 并然后加载杂乱的字体并写入标题来显示启动屏幕。
lcd.fill(lcd.bounds(),color_t::white);
File file = SPIFFS.open("/MIDI.jpg","rb");
draw::image(lcd,rect16(0,0,319,144).center_horizontal(lcd.bounds()),&file);
file.close();
file = SPIFFS.open("/PaulMaul.ttf","rb");
file.seek(0,SeekMode::SeekEnd);
size_t sz = file.position()+1;
file.seek(0);
prang_font_buffer=(uint8_t*)malloc(sz);
if(prang_font_buffer==nullptr) {
Serial.println("Out of memory loading font");
while(true);
}
file.readBytes((char*)prang_font_buffer,sz);
prang_font_buffer_size = sz;
file.close();
const_buffer_stream fntstm(prang_font_buffer,prang_font_buffer_size);
open_font prangfnt;
gfx_result gr = open_font::open(&fntstm,&prangfnt);
if(gr!=gfx_result::success) {
Serial.println("Error loading font.");
while(true);
}
const char* title = "pr4nG";
float title_scale = prangfnt.scale(200);
ssize16 title_size = prangfnt.measure_text(ssize16::max(),
spoint16::zero(),
title,
title_scale);
draw::text(lcd,
title_size.bounds().center_horizontal((srect16)lcd.bounds()).offset(0,45),
spoint16::zero(),
title,
prangfnt,
title_scale,
color_t::red,
color_t::white,
true,
true);
现在,在显示启动屏幕的同时,我们首先检查以确保用户插入了 SD 卡,或者提示插入。使用一个巧妙的小技巧,一旦插入 SD 卡,我们就可以自动继续。我们运行一个循环,并在其中,我们反初始化并重新初始化 SD 卡,尝试读取根目录直到成功。
总之,一旦我们成功读取了 SD 目录,我们就依次加载每个文件,首先计算实际的 MIDI 文件数。
然后我们再次这样做。第一次运行只是为了获取计数,以便知道需要分配多少内存来保存文件列表。第二次运行是为了将实际数据加载到我们刚刚分配的内存中。
if(SD.cardSize()==0) {
draw_error("insert SD card");
while(true) {
SD.end();
SD.begin(SD_CS,spi_container<0>::instance());
file=SD.open("/","r");
if(!file) {
delay(1);
} else {
file.close();
break;
}
}
free(prang_font_buffer);
goto restart;
}
file = SD.open("/","r");
size_t fn_count = 0;
size_t fn_total = 0;
while(true) {
File f = file.openNextFile();
if(!f) {
break;
}
if(!f.isDirectory()) {
const char* fn = f.name();
size_t fnl = strlen(fn);
if((fnl>5 && ((0==strcmp(".midi",fn+fnl-5) ||
(0==strcmp(".MIDI",fn+fnl-5) ||
(0==strcmp(".Midi",fn+fnl-5))))))||
(fnl>4 && (0==strcmp(".mid",fn+fnl-4) ||
0==strcmp(".MID",fn+fnl-4)) ||
0==strcmp(".Mid",fn+fnl-4))) {
++fn_count;
fn_total+=fnl+1;
}
}
f.close();
}
file.close();
char* fns = (char*)malloc(fn_total+1)+1;
if(fns==nullptr) {
draw_error("too many files");
while(1);
}
midi_file* mfs = (midi_file*)malloc(fn_total*sizeof(midi_file));
if(mfs==nullptr) {
draw_error("too many files");
while(1);
}
file = SD.open("/","r");
char* str = fns;
int fi = 0;
while(true) {
File f = file.openNextFile();
if(!f) {
break;
}
if(!f.isDirectory()) {
const char* fn = f.name();
size_t fnl = strlen(fn);
if((fnl>5 && ((0==strcmp(".midi",fn+fnl-5) ||
(0==strcmp(".MIDI",fn+fnl-5) ||
(0==strcmp(".Midi",fn+fnl-5))))))||
(fnl>4 && (0==strcmp(".mid",fn+fnl-4) ||
0==strcmp(".MID",fn+fnl-4)) ||
0==strcmp(".Mid",fn+fnl-4))) {
memcpy(str,fn,fnl+1);
str+=fnl+1;
file_stream ffs(f);
midi_file::read(&ffs,&mfs[fi]);
++fi;
}
}
f.close();
}
file.close();
基本上,对于 SD 卡上的每个 MIDI 文件,我们都保留一个 midi_file
和文件名,并且我们保证文件名之前至少有一个字符是有效的写入位置,因为在我们加载文件之前,我们只需将该预先添加的位置设置为 '/
' 来构成文件名路径。这避免了字符串复制。这虽然微不足道,但更多的是关于不必编写太多代码。
我们将整个列表加载到 RAM 中的原因是,您的编码器手指比 SD 读卡器快很多。我们希望显示屏响应灵敏,因此将列表保留在 RAM 中可以做到这一点。加载每个文件而不是仅仅检索名称的原因是,我们可以用蓝色显示 MIDI 类型 1 文件,用黑色显示 MIDI 类型 2 文件,同时还显示每种文件的音轨数量。实际上,我们可以使用比 midi_file
更小的结构体,节省一些字节,因为我们不需要它包含的所有信息。
接下来,我们关闭根目录。之后,我们检查是否有超过 1 个文件。如果有,我们就显示文件选择屏幕,否则我们就加载单个文件。
文件选择屏幕实际上有点复杂,但很容易解释
const char* seltext = "select filE";
float fscale = prangfnt.scale(80);
ssize16 tsz = prangfnt.measure_text(ssize16::max(),spoint16::zero(),seltext,fscale);
srect16 trc = tsz.bounds().center_horizontal((srect16)lcd.bounds());
draw::text(lcd,trc.offset(0,20),spoint16::zero(),seltext,prangfnt,
fscale,color_t::red,color_t::white,false);
fscale = Telegrama_otf.scale(20);
bool done = false;
size_t fni=0;
int64_t ocount = encoder.getCount()/4;
int osw = digitalRead(P_SW1) || digitalRead(P_SW2) ||
digitalRead(P_SW3) || digitalRead(P_SW4);
while(!done) {
tsz= Telegrama_otf.measure_text(ssize16::max(),spoint16::zero(),curfn,fscale);
trc = tsz.bounds().center_horizontal((srect16)lcd.bounds()).offset(0,110);
draw::filled_rectangle(lcd,srect16(0,trc.y1,lcd.dimensions().width-1,
trc.y2+trc.height()+5),color_t::white);
rgb_pixel<16> px=color_t::black;
if(mfs[fni].type==1) {
px=color_t::blue;
} else if(mfs[fni].type!=2) {
px=color_t::red;
}
draw::text(lcd,trc,spoint16::zero(),curfn,Telegrama_otf,
fscale,px,color_t::white,false);
char szt[64];
sprintf(szt,"%d tracks",(int)mfs[fni].tracks_size);
tsz= Telegrama_otf.measure_text(ssize16::max(),spoint16::zero(),szt,fscale);
trc = tsz.bounds().center_horizontal((srect16)lcd.bounds()).offset(0,133);
draw::text(lcd,trc,spoint16::zero(),szt,Telegrama_otf,fscale,
color_t::black,color_t::white,false);
bool inc;
while(ocount==(encoder.getCount()/4)) {
int sw = digitalRead(P_SW1) || digitalRead(P_SW2) ||
digitalRead(P_SW3) || digitalRead(P_SW4);
if(osw!=sw && !sw) {
// button was released
done = true;
break;
}
osw=sw;
delay(1);
}
if(!done) {
int64_t count = (encoder.getCount()/4);
inc = ocount>count;
ocount = count;
if(inc) {
if(fni<fn_count-1) {
++fni;
curfn+=strlen(curfn)+1;
}
} else {
if(fni>0) {
--fni;
curfn=fns;
for(int j = 0;j<fni;++j) {
curfn+=strlen(curfn)+1;
}
}
}
}
}
我们开始绘制“选择文件”,然后,我们在这里做的主要事情是绘制当前文件信息,并等待编码器更改或按下其中一个按钮。
在主循环中,我们首先等待编码器。在这个内部循环中,我们还查找按钮按下。如果编码器发生变化,我们通过比较当前计数和旧计数来确定方向。您会注意到我们除以 4。这是为了让编码器在旋钮从凹槽滑落到凹槽时调整值。否则,您会在编码器的一次“咔嗒”声中获得多个更改信号。
其中 curfn
保存我们的当前文件名字符串,fni
保存文件名索引。向前移动只是指针前进。向后移动涉及从头开始,然后前进指针,直到我们到达比我们少一的位置。因为它在 RAM 中,所以基本上都是即时的。
一旦我们完成了这些,或者如果只有一个文件并且我们绕过了它,我们就进入启动代码的下一部分。我们初始化输出,因为 MIDI USB 设备需要一点时间才能启动。然后我们重置我们的速度乘数。我想我们之前不必这样做,但这样做也没有坏处。我们重新采样了旧的编码器值,以便下次读取它时,它不会注册一个错误的更改。然后,我们像之前介绍的那样预置我们的 '/
',然后打开文件,之后释放我们之前创建的文件列表数据。
// avoids the 1 second init delay later
out.initialize();
tempo_multiplier = 1.0;
encoder_old_count = encoder.getCount()/4;
--curfn;
*curfn='/';
file = SD.open(curfn, "rb");
if(!file) {
draw_error("re-insert SD card");
while(true) {
SD.end();
SD.begin(SD_CS,spi_container<0>::instance());
file=SD.open(curfn,"rb");
if(!file) {
delay(1);
} else {
break;
}
}
}
::free(fns-1);
::free(mfs);
现在我们绘制下一个屏幕——播放屏幕,并使用刚刚打开的文件加载采样器。如果内存不足,我们会报错并重启,尽管实际上,大多数 MIDI 文件应该都没有问题被加载,即使没有 PSRAM。
draw::filled_rectangle(lcd,lcd.bounds(),color_t::white);
const char* playing_text = "pLay1nG";
float playing_scale = prangfnt.scale(125);
ssize16 playing_size = prangfnt.measure_text(ssize16::max(),spoint16::zero(),
playing_text,playing_scale);
draw::text(lcd,playing_size.bounds().center((srect16)lcd.bounds()),
spoint16::zero(),playing_text,prangfnt,playing_scale,color_t::red,
color_t::white,false);
free(prang_font_buffer);
file_stream fs(file);
sfx_result r=midi_sampler::read(&fs,&sampler);
if(r!=sfx_result::success) {
switch(r) {
case sfx_result::out_of_memory:
file.close();
draw_error("file too big");
delay(3000);
goto restart;
default:
file.close();
draw_error("not a MIDI file");
delay(3000);
goto restart;
}
}
file.close();
我正在考虑将上述检查移到我最初从 SD 卡读取文件时。这将使启动屏幕花费更长的时间,但我并不介意。优点是文件列表不会包含无法加载的 MIDI。对于音乐家来说,这很方便,因为音乐家通常不擅长技术,而且最好使“做错事 (TM)”变得困难。
现在我们将采样器的输出设置为指定值,然后向显示线程发送我们的初始消息以更新速度乘数到其初始值。如果我们不这样做,速度乘数的值将不会显示,直到它被更改。
sampler.output(&out);
xRingbufferSend(signal_queue,&tempo_multiplier,sizeof(tempo_multiplier),0);
最后,我们进入主应用程序循环。这就是魔法发生的地方。
循环的第一部分处理编码器更改,更新速度乘数。
int64_t ec = encoder.getCount()/4;
if(ec!=encoder_old_count) {
bool inc = ec<encoder_old_count;
encoder_old_count=ec;
if(inc && tempo_multiplier<=4.9) {
tempo_multiplier+=.1;
sampler.tempo_multiplier(tempo_multiplier);
xRingbufferSend(signal_queue,&tempo_multiplier,sizeof(tempo_multiplier),0);
} else if(tempo_multiplier>.1) {
tempo_multiplier-=.1;
sampler.tempo_multiplier(tempo_multiplier);
xRingbufferSend(signal_queue,&tempo_multiplier,sizeof(tempo_multiplier),0);
}
}
循环的后半部分更长,但大部分是重复的。它处理四个按钮,并且对于每个按钮,逻辑都相同,因此可以放在一个循环中,并且最终也会这样做。我将在下面介绍其中的两个,因为接下来的两个是一样的。
bool first_track = follow_track == -1;
bool changed = false;
int b=digitalRead(P_SW1);
if(b!=switches[0]) {
changed = true;
if(b) {
if(first_track) {
follow_track = 0;
sampler.start(0);
} else {
sampler.start(0,sampler.elapsed(follow_track) %
sampler.timebase(follow_track));
}
} else {
sampler.stop(0);
}
switches[0]=b;
}
b=digitalRead(P_SW2);
if(b!=switches[1]) {
changed = true;
if(b) {
if(first_track) {
follow_track = 1;
sampler.start(1);
} else {
sampler.start(1,sampler.elapsed(follow_track) %
sampler.timebase(follow_track));
}
} else {
sampler.stop(1);
}
switches[1]=b;
}
这实现了量化逻辑以及根据需要启动和停止音轨。量化目前仅适用于迟到的按下。它的作用是修改时间基准以找到落在节拍上的下一个节拍,并在循环开始时以此量进行推进。这可能意味着音符可能会被跳过,因为与使用采样器执行此操作不同,您无法让 MIDI 乐谱播放半个音符。这是协议的一个限制,没有已知的解决方法,因此我的代码中没有任何内容可以修复。
结论
这就是核心内容。现在,请使用 SFX 和 GFX 去制作您自己的音乐 MIDI 小工具吧!
历史
- 2022 年 5 月 16 日 - 初次提交