65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2022 年 5 月 16 日

MIT

20分钟阅读

viewsIcon

7972

downloadIcon

84

从 Type 2 MIDI 文件触发音轨,添加到您的录音或表演中。

prang

引言

我最初将 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 文件夹下使用了 ESPTinyUSBESP32Encoder。这些部分不是我写的。我将它们与项目一起分发,因为据我所知,它们不在 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 表示实际播放速度与基本微秒速度的比较。bufferbuffer_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 日 - 初次提交
© . All rights reserved.