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

小型设备的先进流式传输: MIDI 文件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2022年5月9日

MIT

22分钟阅读

viewsIcon

8058

downloadIcon

92

探索一些流式传输技术, 同时让您的闪亮新 ESP32S3 完成一些 USB 和 MIDI 技巧。

Screenshot

引言

当输入数据基本上就是为此而构建时,进行流式传输很容易——所有数据都是顺序的,你可以简单地按照它们出现的顺序提取信息,而且在理想情况下,所有需要流式传输的内容都将采用适合顺序迭代处理的格式。但这并非理想世界。再说一遍,给后排的各位听清楚。

这就引出了多轨 MIDI 文件。曾几何时,有人——或者更确切地说,某个委员会——认为将 MIDI 序列分解成多个轨道是个好主意,而不是简单地在一个轨道上使用多个通道。从组织的角度来看,这还不错,虽然不是关键,但它绝对会扼杀你按顺序流式传输数据的能力。

这是因为每个轨道只包含其序列或组合的一部分。本质上,为了真正播放其中的任何内容,你必须将轨道数据重新交错回一个单一的流中。

为了稍微复杂化一点,每个 MIDI 事件都带有一个 delta 值,该值表示它相对于前一个事件的 MIDI tick(我们将稍后讨论)的偏移量。这些 delta 值相对于轨道。当你生成一个单一的流时,所有轨道中的 delta 值必须重新计算,以便在事件交错时它们是相互相对的。

如果我们将 MIDI 文件加载到内存中,然后提前计算所有内容,那将大大减轻我们的痛苦,但我拥有的 Queen 的“Bohemian Rhapsody”表演文件超过 50KB。最好不要分配那么多空间,这就是为什么我们将一次只流式传输一点数据。

我们将在 ESP32S3 上运行此程序。如果你没有,可以使用 S2,甚至带有外部可编程 USB 分离板的 ESP32,但你必须编辑你的 platformio.ini 文件。

我们将使用 PlatformIO,如果你还没有使用,那么你应该使用。Arduino IDE 1.x 的功能不足以编译我的代码,我的代码需要 C++14 或更高版本。

为了测试目的,最好将第二个 USB 端口连接到你的 PC(这意味着你的设备和 PC 之间将有两根电缆),然后下载并运行 MIDI-OX(如果你使用的是 Windows)。如果你使用的是其他操作系统,你可能需要不同的 MIDI 连接器/监视器软件。无论如何,你可以使用该软件将来自 USB 的输入 MIDI 连接到声卡的输出 MIDI,这样你就可以听到播放。

另外,请记住第一次上传文件系统映像,否则一切都不会起作用。

我已将 ESPTinyUsb 与此项目一起打包在“lib”文件夹中。这不是我的作品。我们使用它将 USB 端口编程为 MIDI 设备。

概念化这个混乱的局面

好了,我们开始吧。我将解释这个项目的大致思路,然后详细介绍 MIDI 线协议和 MIDI 文件格式的细节。

首先,这是我们要实现的目标

该项目的目标是读取 MIDI 文件,然后将其循环播放到任何监听设备。

协议格式

一般信息

MIDI 协议是一个 8 位数字线协议,于 20 世纪 80 年代开发,用于控制乐器。它是一种大端协议,传统上使用 5 针 DIN 连接器进行物理连接,虽然这些连接器仍在沿用,但现在通过 USB 连接已成为可能且相当普遍。一些设备还可以通过蓝牙连接。

消息格式

以下指南作为 MIDI 协议格式的教程呈现,但不必完全熟悉它才能跟上。

MIDI 通过“消息”工作,这些消息告诉乐器该做什么。MIDI 消息分为两类:*通道消息*和*系统消息*。通道消息构成了数据流的大部分,并承载演奏信息,而系统消息则控制全局/环境设置。

通道消息之所以称为通道消息,是因为它被定向到一个特定的通道。每个通道可以控制自己的乐器,并且最多有 16 个通道可用,其中通道 #10(零基索引 9)是一个特殊通道,始终承载打击乐信息,而其他通道则映射到任意设备。这意味着 MIDI 协议能够一次与多达 16 个独立设备通信。

系统消息之所以称为系统消息,是因为它控制适用于所有通道的全局/环境设置。一个例子是通过“系统独占”或“sysex”消息发送特定硬件的专有信息。另一个例子是 MIDI 文件中包含的特殊信息(但在线协议中不存在),例如文件回放的速度。另一个系统消息的例子是“系统实时消息”,它允许访问传输功能(播放、停止、继续和设置传输设备的定时)。

每个 MIDI 消息都有一个与之关联的“状态字节”。这通常是 MIDI 消息中的第一个字节。状态字节在高四位(4 位)中包含消息 ID,在低四位中包含目标通道。因此,状态字节 0xC5 表示通道消息类型为 0xC,目标通道为 0x5。高四位必须大于或等于 0x8。如果高四位是 0xF,则这是一个系统消息,并且整个状态字节就是消息 ID,因为没有通道。例如,0xFF 是 MIDI “元事件”消息的消息 ID,可以在 MIDI 文件中找到。同样,低四位是状态字节的一部分(如果高四位是 0xF)。

** 由于协议的优化,状态字节可能会被省略,在这种情况下将使用前一条消息的状态字节。这允许发送具有相同状态但参数不同的消息“运行”,而无需为每条消息重复重复的字节。

以下通道消息可用

  • 0x8 Note Off - 释放指定的音符。音符的力度包含在此消息中,但未使用。具有指定音符 ID 的所有音符都将被释放,因此如果有两个 Note On 后跟一个 Note Off 来表示 C#4,则该通道上的所有 C#4 音符都会被释放。此消息包含 3 个字节,包括状态字节。第二个字节是音符 ID(0-0x7F/127),第三个字节是力度(0-0x7F/127)。力度几乎从未用于 Note Off 消息。我不确定为什么它存在。我所遇到过的任何东西都没有使用它。它通常设置为零,或者可能是对应 Note On 的音符力度。这真的不重要。
  • 0x9 Note On - 击打并按住指定的音符,直到找到相应的 Note Off 消息。此消息包含 3 个字节,包括状态字节。参数与 Note Off 相同。应注意,力度为零的 Note On 实际上相当于 Note Off。
  • 0xA Key Pressure/Aftertouch - 指示按键被按下的压力。这通常用于支持它的高端键盘,通过按键的压力产生按键按下后的效果。此消息包含 3 个字节,包括状态字节。第二个字节是音符 ID(0-0x7F/127),第三个字节是压力(0-0x7F/127)。
  • 0xB Control Change - 表示要将控制器值更改为指定值。控制器因乐器而异,但存在用于平移等常用控件的标准控件代码。此消息包含 3 个字节,包括状态字节。第二个字节是控件 ID。存在常用 ID,如平移(0x0A/10)和音量(7),以及许多自定义 ID,通常是硬件特定或可在硬件中自定义映射到不同参数的。在这里可以找到一个包含标准和可用自定义代码的表 here。第三个字节是值(0-0x7F/127),其含义很大程度上取决于第二个字节是什么。
  • 0xC Patch/Program Change - 一些设备有多个不同的“程序”或设置,可以产生不同的声音。例如,你的合成器可能有一个模拟电钢琴的程序和一个模拟弦乐合奏的程序。此消息允许你设置要由设备播放的声音。此消息包含 2 个字节,包括状态字节。第二个字节是音色/程序 ID(0-0x7F/127)。
  • 0xD Channel Pressure/Non-Polyphonic Aftertouch - 这类似于 aftertouch 消息,但适用于不支持复音 aftertouch 的不太复杂的乐器。它会影响整个通道而不是单个琴键,因此会影响所有正在演奏的音符。它被指定为所有按下琴键的最高 aftertouch 值。此消息包含 2 个字节,包括状态字节。第二个字节是压力(0x7F/127)。
  • 0xE Pitch Wheel Change - 这表明音高轮已移动到新位置。这通常会对通道中的所有音符施加一个整体的音高修改器,使得当滚轮向上移动时,所有正在播放的音符的音高会相应增加,而向下移动时则相反。此消息包含 3 个字节,包括状态字节。第二个和第三个字节分别包含最低 7 位(0-0x7F/127)和最高 7 位,从而产生一个 14 位值。

以下系统消息可用(非详尽列表)

  • 0xF0 System Exclusive - 这表示要将设备特定的数据流发送到 MIDI 输出端口。消息的长度可变,并以“结束系统独占”消息为界。我还不清楚这如何传输,但它在文件格式中与在线协议不同,因此是独一无二的。在文件中,长度紧跟在状态字节之后,并编码为“可变长度数量”,稍后将进行介绍。最后,跟随的是指定字节长度的数据。
  • 0xF7 End of System Exclusive - 这表示系统独占消息流的结束标记。
  • 0xFF Meta Message - 这在 MIDI 文件中定义,但在线协议中未定义。它指示特定于文件的特殊数据,例如文件应播放的速度,以及有关乐谱的附加信息,例如序列名称、各个轨道名称、版权声明,甚至歌词。这些可能具有任意长度。状态字节后面是一个字节,指示元消息的“类型”,然后是一个“可变长度数量”,指示长度,再次跟上数据。

以下是消息在有线传输中的样子示例。

音符开,中央 C,通道 0 上的最大力度

90 3C 7F

通道 2 上的音色更改为 1

C2 01

请记住,状态字节可以省略。这里有一些在连续消息中发送到通道 0 的音符开消息。

90 3C 7F 3F 7F 42 7F

这会产生中央 C 的 C 大调和弦。每条省略了状态字节的消息都使用前一条状态字节 0x90。

MIDI 文件格式

一旦您理解了 MIDI 有线协议,文件格式就相当简单,因为平均 MIDI 文件中有 80% 或更多是带有时间戳的 MIDI 消息。

MIDI 文件通常具有“*.mid”扩展名,并且与线协议一样,它是大端格式。MIDI 文件按“块”布局。“块”是指 FourCC 代码(只是一个 4 字节的 ASCII 代码),指示块类型,后跟一个 4 字节整数值,指示块的长度,然后跟随指示长度的字节流。文件中第一个块的 FourCC 始终是“MThd”。另一个相关块类型的 FourCC 是“MTrk”。所有其他块类型都是专有的,除非被理解,否则应被忽略。块按顺序,背靠背地排列在文件中。

第一个块“MThd”的长度字段始终设置为 6 字节。其后的数据是 3 个 2 字节整数。第一个表示 MIDI 文件类型,几乎总是 1,但简单文件可以是类型 0,还有一个专用类型 - 类型 2 - 用于存储模式。第二个数字是文件中“轨道”的数量。MIDI 文件可以包含多个轨道,每个轨道可以包含自己的乐谱。第三个数字是 MIDI 文件的“时间基”(通常是 480),表示每四分音符的 MIDI“tick”数。一个 tick 代表的时间取决于当前的速度。

接下来的块是“MTrk”块或专有块。我们跳过专有块,并读取找到的每个“MTrk”块。一个“MTrk”块代表一个 MIDI 文件轨道(如下所述)——本质上只是带有时间戳的 MIDI 消息。带时间戳的 MIDI 消息称为 MIDI“事件”。时间戳以 delta 值指定,每个时间戳是自上一个时间戳以来的 tick 数。这些以一种奇怪的方式编码在文件中。这是 20 世纪 80 年代以及当时有限的磁盘空间和内存(尤其是在硬件音序器上)的副产品——节省的每一个字节都很重要。delta 值使用“可变长度数量”进行编码。

可变长度数量的编码如下:每字节 7 位,最高有效位在前(小端!)。除最后一个字节必须小于 0x80 外,每个字节都大于(大于 0x7F)。如果值在 0 到 127 之间,则由一个字节表示,如果大于此范围,则需要更多字节。可变长度数量理论上可以是任何大小,但实际上它们不能超过 0xFFFFFFF - 大约 3.5 字节。你可以用一个 int 来存储它们,但是读写它们可能会很麻烦。

可变长度数量 delta 之后是一个 MIDI 消息,至少有一个字节,但它的长度会根据消息类型而变化,并且某些消息类型(元消息和 sysex 消息)是可变长度的。它可能在没有状态字节的情况下写入,在这种情况下会使用前一个状态字节。你可以通过检查流中的字节是否大于 0x7F (127) 来判断它是否是状态字节,而所有消息的有效载荷都将是小于 0x80 (128) 的字节。它不像听起来那么难读。基本上,对于每条消息,你检查当前字节是否为高位(> 0x7F/127),如果是,那就是你的新运行状态字节,以及消息的状态字节。如果它是低位,你只需咨询当前状态字节而不是设置它。

MIDI 文件轨道

MIDI 类型 1 文件通常包含多个“轨道”(上面简要提及)。一个轨道通常代表一个乐谱,多个轨道共同构成整个演奏。虽然通常是这样布局的,但实际上是通道而不是轨道指示特定设备要演奏哪个乐谱。也就是说,通道 0 的所有音符将被视为同一乐谱的一部分,即使它们分散在不同的轨道中。轨道只是一个有用的组织方式。它们实际上并不改变 MIDI 的行为。在 MIDI 类型 1 文件(最常见的类型)中,轨道 0 是“特殊的”。它通常不包含演奏消息(通道消息)。相反,它通常包含元信息,如速度和歌词,而其余轨道包含演奏信息。这样布局文件可以确保与现有的 MIDI 设备最大程度的兼容性。

非常重要:轨道必须始终以 MIDI “结束轨道”元消息结尾。

尽管轨道在概念上是分开的,但乐谱的分隔实际上是在底层按通道进行的,而不是按轨道。这意味着你可以有多个轨道,当组合在一起时,它们代表特定通道(或多个通道)设备的乐谱。你可以根据需要组合通道和轨道,只需记住,同一通道的所有通道消息都代表单个设备的实际乐谱,而轨道本身基本上是虚拟/抽象的便利项。

有关 MIDI 线协议和 MIDI 文件格式的更多信息,请参阅此页面

编写这个混乱的程序

我们要做的第一件事是加载文件。我们只需要从文件中获取一些基本信息,例如轨道的数量、偏移量和大小。

为此,我们有简单的 midi_trackmidi_file 结构体。

// represents a MIDI track entry in a MIDI file
struct midi_track final {
    // the size of the track in bytes
    size_t size;
    // the offset where the track begins
    size_t offset;
};
// represents the data in a MIDI file
class midi_file final {
    void copy(const midi_file& rhs);
public:
    // The type of MIDI file
    int16_t type;
    // The timebase
    int16_t timebase;
    // The number of tracks
    size_t tracks_size;
    // the track entries
    midi_track* tracks;
    // constructs a new instance
    midi_file();
    // steals an instance
    midi_file(midi_file&& rhs);
    // steals an instance 
    midi_file& operator=(midi_file&& rhs);
    // copies an instance
    midi_file(const midi_file& rhs);
    // copies an instance
    midi_file& operator=(const midi_file& rhs);
    // destroys an instance
    ~midi_file();
    // reads a file from a stream
    static sfx_result read(stream* in, midi_file* out_file);
};

关于 midi_file 真正重要的只有数据成员和 read() 方法。我们将在下一步探讨该函数。

对于 read(),我们采用一个打开的流并读取之前提到的所有“块”,提取重要数据。我们只关心 FourCC 为“MThd”和“MTrk”的块。其他块将被忽略。由于格式是大端序,我们必须交换我们每个字的值的字节。

// reads a chunk out of a multipart chunked file (MIDI file, basically)
bool midi_file_read_chunk_part(stream* in,size_t* in_out_offset, size_t* out_size) {
    uint32_t tmp;
    // read the size
    if(4!=in->read((uint8_t*)&tmp,4)) {
        return false;
    }
    *in_out_offset+=4;
    if(bits::endianness()==bits::endian_mode::little_endian) {
        tmp = bits::swap(tmp);
    }
    *out_size=(size_t)tmp;
    return true;
}
...
// read file info from a stream
sfx_result midi_file::read(stream* in, midi_file* out_file) {
    // MIDI files are a series of "chunks" that are a 4 byte ASCII string
    // "magic" identifier, and a 4 byte integer size, followed by 
    // that many bytes of data. After that is the next chunk
    // or the end of the file.
    // the two relevant chunks are MThd (always size of 6)
    // that contains the MIDI file global info
    // and then MTrk for each MIDI track in the file
    // chunks with any other magic id are ignored.
    if(in==nullptr||out_file==nullptr) {
        return sfx_result::invalid_argument;
    }
    if(!in->caps().read) {
        return sfx_result::io_error;
    }
    int16_t tmp;
    union {
        uint32_t magic_id;
        char magic[5];
    } m;
    m.magic[4]=0;
    size_t pos = 0;
    size_t sz;
    if(4!=in->read((uint8_t*)m.magic,4)) {
        return sfx_result::invalid_format;
    }
    if(0!=strcmp(m.magic,"MThd")) {
        return sfx_result::invalid_format;
    }
    pos+=4;
    if(!midi_file_read_chunk_part(in,&pos,&sz) || 6!=sz) {
        return sfx_result::invalid_format;
    }
    
    if(2!=in->read((uint8_t*)&tmp,2)) {
        return sfx_result::end_of_stream;
    }
    if(bits::endianness()==bits::endian_mode::little_endian) {
        tmp = bits::swap(tmp);
    }
    pos+=2;
    out_file->type = tmp;

    if(2!=in->read((uint8_t*)&tmp,2)) {
        return sfx_result::end_of_stream;
    }
    if(bits::endianness()==bits::endian_mode::little_endian) {
        tmp = bits::swap(tmp);
    }
    pos+=2;
    out_file->tracks_size = tmp;
    if(2!=in->read((uint8_t*)&tmp,2)) {
        return sfx_result::end_of_stream;
    }
    if(bits::endianness()==bits::endian_mode::little_endian) {
        tmp = bits::swap(tmp);
    }
    pos+=2;
    out_file->timebase = tmp;
    out_file->tracks = (midi_track*)malloc(sizeof(midi_track)*out_file->tracks_size);
    if(out_file->tracks==nullptr) {
        return sfx_result::out_of_memory;
    }
    size_t i = 0;
    while(i<out_file->tracks_size) {
        if(4!=in->read((uint8_t*)m.magic,4)) {
            if(out_file->tracks_size==i) {
                return sfx_result::success;
            }
            return sfx_result::invalid_format;
        }
        pos+=4;
        if(!midi_file_read_chunk_part(in,&pos,&sz)) {
            return sfx_result::end_of_stream;
        }
        if(0==strcmp(m.magic,"MTrk")) {
            out_file->tracks[i].offset=pos;
            out_file->tracks[i].size=sz;
            ++i;
        } 
        if(in->caps().seek) {
            in->seek(sz,io::seek_origin::current);
        } else {
            for(int j = 0;j<sz;++j) {
                if(-1==in->getch()) {
                    return sfx_result::end_of_stream;            
                }
            }
        }
        pos+=sz;
    }
    if(i==out_file->tracks_size) {
        return sfx_result::success;
    }
    if(i<out_file->tracks_size) {
        return sfx_result::end_of_stream;
    }
    
    return sfx_result::invalid_format;   
}

一旦我们获得了文件的信息,我们就可以使用它来查找我们的轨道并流式传输它们。我们还获得了文件中以“脉冲每四分音符”为单位的时间基,这有助于我们计算播放文件所需的时序。

现在,既然我们要从文件中提取 MIDI 消息,让我们来看看它们在代码中是什么样子。

// represents the type of MIDI message
enum struct midi_message_type : uint8_t {
    // a note off message
    note_off = 0b10000000,
    // a note on message
    note_on = 0b10010000,
    // polyphonic pressure (aftertouch) message
    polyphonic_pressure = 0b10100000,
    // control change (CC) message
    control_change = 0b10110000,
    // program change/patch select message
    program_change = 0b11000000,
    // channel pressure (aftertouch) message
    channel_pressure = 0b11010000,
    // pitch wheel message
    pitch_wheel_change = 0b11100000,
    // system exclusive (sysex) message
    system_exclusive = 0b11110000,
    // 0b11110001 undefined
    // song position message
    song_position = 0b11110010,
    // song select message
    song_select = 0b11110011,
    // b11110100 undefined
    // b11110101 undefined
    // tune request message
    tune_request = 0b11110110,
    // end of system exclusive message
    end_system_exclusive = 0b11110111,
    // timing clock message
    timing_clock = 0b11111000,
    // 0b11111001 undefined
    // start message
    start_playback = 0b11111010,
    // continue message
    continue_playback = 0b11111011,
    // stop message
    stop_playback = 0b11111100,
    // 0b11111101 undefined
    // active sensing message
    active_sensing = 0b11111110,
    // reset message
    reset = 0b11111111,
    // MIDI file meta event message
    meta_event = 0b11111111
};
// represents a MIDI message
class midi_message final {
    void copy(const midi_message& rhs);
    void deallocate();
public:
    // the status byte
    uint8_t status;
    union {
        // the 8-bit value holder for a message with a single byte payload
        uint8_t value8;
        // the 16-bit value holder for a message with a two byte payload
        uint16_t value16;
        // systex information (type()==system_exclusive)
        struct {
            // the data
            uint8_t* data;
            // the size of the data
            size_t size;
        } sysex;
        // meta event information (type()==meta_event - MIDI files only) 
        struct {
            // the type of message
            uint8_t type;
            // the length encoded as a varlen 
            uint8_t encoded_length[3];
            // the meta data
            uint8_t* data;
        } meta;
    };
    // constructs a new message
    inline midi_message() : status(0) {
        memset(this,0,sizeof(midi_message));
        meta.data = nullptr;
    }
    // destroys a message
    inline ~midi_message() {
        deallocate();
    }
    // copies a message
    inline midi_message(const midi_message& rhs) {
        copy(rhs);
    }
    // copies a message
    inline midi_message& operator=(const midi_message& rhs) {
        deallocate();
        copy(rhs);
        return *this;
    }
    // steals a message
    inline midi_message(midi_message&& rhs) {
        memcpy(this,&rhs,sizeof(midi_message));
        memset(&rhs,0,sizeof(midi_message));
    }
    // steals a message
    inline midi_message& operator=(midi_message&& rhs) {
        deallocate();
        memcpy(this,&rhs,sizeof(midi_message));
        memset(&rhs,0,sizeof(midi_message));
        return *this;
    }
    // gets the channel (channel messages only)
    inline uint8_t channel() const {
        if(status<0b11110000) {
            return status & 0xF;
        }
        return 0;   
    }
    // sets the channel (channel messages only)
    inline void channel(uint8_t value) {
        if(status<0b11110000) {
            status = (status & 0xF0) | (value & 0x0F);
        }
    }
    // gets the type of message
    inline midi_message_type type() const {
        if(status<0b11110000) {
            return (midi_message_type)(status&0xF0);
        } else {
            return (midi_message_type)(status);
        }
    }
    // sets the type of message
    inline void type(midi_message_type value) {
        if(((int)value)<0xb11110000) {
            status = (status & 0x0F) | (((int)value));
        } else {
            status = (uint8_t)value;
        }
    }
    // get the MSB value for messages with a 2 byte payload
    inline uint8_t msb() const {
        return (value16 >> 8)&0x7f;
    }
    // set the MSB value for messages with a 2 byte payload
    inline void msb(uint8_t value) {
        value16 = (value16 & 0x7f) | uint16_t((value & 0x7f)<<8);
    }
    // get the LSB value for messages with a 2 byte payload
    inline uint8_t lsb() const {
        return value16 & 0x7f;
    }
    // set the LSB value for messages with a 2 byte payload
    inline void lsb(uint8_t value) {
        value16 = (value16 & uint16_t(0x7f<<8)) | (value & 0x7f);
    }
    // indicates the size of the message over the wire
    inline size_t wire_size() const {
        int32_t result;
        
        switch(type()) {
        case midi_message_type::note_off:
        case midi_message_type::note_on:
        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:
            return 3;
        case midi_message_type::program_change:
        case midi_message_type::channel_pressure:
        case midi_message_type::song_select:
            return  2;
        case midi_message_type::system_exclusive:
            return sysex.size+1;
        case midi_message_type::reset:
            if(meta.type&0x80) {
                return 1;
            } else {
                const uint8_t* p=midi_utility::decode_varlen(meta.encoded_length,&result);
                if(p!=nullptr) {
                    return (size_t)result+(p-meta.encoded_length)+2;
                }
            }
        
            return 1;
        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:
            return 1;
        default:
            return 1;
        }
    }
};

这里有很多内容,但我已经添加了注释,我将在此处解释更大的图景。对于大多数消息,如果包含状态字节,它们最多是 3 个字节。例外是 sysex 和元消息。基本上,对于除了这两种消息之外的任何消息,我们都可以将数据保存在顶部的 union 中。其中还包括指向元消息和 sysex 消息的指针。这样,我们至少在大多数情况下,可以有一个单一大小的结构体来表示任何消息。其余的是用于获取消息各个部分的辅助方法,以及返回消息在网络上传输或文件中的实际大小(包括状态字节)的 wire_size() 函数。

midi_event 还包含一个 delta 值,该值指示相对于前一条消息的 MIDI tick 偏移量。

// represents a MIDI event
struct midi_event final {
    // the offset in MIDI ticks from the previous event
    int32_t delta;
    // the MIDI message
    midi_message message;
};

应注意,delta 在文件中存储为 *varlen* 数字,这是一个压缩整数,占 1 到 3 个字节。

让我们看一下 midi_utility 来看看解码一个是什么样的。

size_t midi_utility::decode_varlen(stream* in, 
                                int32_t* out_value) {
    uint8_t c;
    uint32_t value;
    size_t result = 1;
    if ((value = (uint8_t)in->getch()) & 0x80) {
        value &= 0x7f;
        do {
            value = (value << 7) + 
                ((c = (uint8_t)in->getch()) & 0x7f);
            ++result;
        } while (c & 0x80 && result < 4);
    }
    *out_value = value;
    
    return result > 3 ? 0 : result;
}

基本上,只要值设置了最高位,我们就取低 7 位并将其移位/加到我们的数字中。我们这样做,直到我们读取 3 个字节或该值不再设置最高位。

现在我们几乎拥有了帮助我们从文件中读取事件的所有工具。这是最后一点,包括我们将要构建的函数的头部。

// the MIDI event plus an absolute position within the stream
struct midi_stream_event final {
    // the absolute position in MIDI ticks
    unsigned long long absolute;
    // the delta from the last event in MIDI ticks
    int32_t delta;
    // the MIDI message
    midi_message message;
};
// represents a class for fetching MIDI messages out of a stream
class midi_stream final {
public:
    // decode the next event. The contents of the 
    // in_out_event should be preserved between calls to this method.
    static const size_t decode_event(bool is_file, 
                                    stream* in, 
                                    midi_stream_event* in_out_event);
};

你在这里可以看到,我们基本上用绝对位置增强了一个 MIDI 事件。这是为了让我们能够跟踪我们在流中的位置。我们可以在更高级别上做到这一点,但我决定将其放在这里,因为在实践中,当你提取事件时,你实际上需要这个数字。

这里应该注意的一件事是,midi_stream_event 实例既是输入也是输出值。原因之一是为了我们可以跟踪绝对位置。另一个原因至少和第一个一样重要。我之前提到过,我们可以省略具有相同状态的消息运行的状态字节。传入旧消息可以实现此功能。你第一次调用该例程时,传入一个空的(新创建的)事件。之后,你在进行时继续传入同一个实例。至少理论上是这样。实际上它有效,但有一个我们最终会遇到的问题。

你可能注意到了 is_file。MIDI 文件将 MIDI 重置消息状态与自己的消息类型(只能存在于文件中)相结合,即 MIDI 元消息。不幸的是,这会在消息流中造成某种歧义。在文件中,我们查找元事件。在其他任何地方,我们都将其视为重置。

现在让我们进入函数的核心部分,它老实说相当糟糕,但协议要求如此。可以抽象这段代码,但这会带来自己的成本。

const size_t midi_stream::decode_event(bool is_file, stream* in, 
      midi_stream_event* in_out_event) {
    if (in == nullptr || in_out_event == nullptr) {
        return 0;
    }
    int32_t delta;
    size_t result = midi_utility::decode_varlen(in,&delta);
    
    in_out_event->absolute+=delta;
    in_out_event->delta=delta;
    int i = in->getch();
        if(i==-1) {
        return 0;
    }
    ++result;
    uint8_t b = (uint8_t)i;
    if(in_out_event->message.status==0xFF && in_out_event->message.meta.data!=nullptr) {
        free(in_out_event->message.meta.data);
        in_out_event->message.meta.data=nullptr;
    }
    if(in_out_event->message.status==0xF7 && in_out_event->message.sysex.data!=nullptr) {
        free(in_out_event->message.sysex.data);
        in_out_event->message.sysex.data=nullptr;
    }
    bool has_status = b&0x80;
    // expecting a status byte
    if(!has_status) {
        if(!(in_out_event->message.status&0x80)) {
            // no status byte in message
            return 0;
        }
    } else {
        in_out_event->message.status = b;
    }
    switch(in_out_event->message.type()) {
    case midi_message_type::note_off:
    case midi_message_type::note_on:
    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:
        if(has_status) {
            if(2!=in->read((uint8_t*)&in_out_event->message.value16,2)) {
                return 0;
            }
            result+=2;
            return result;
        }
        i=in->getch();
        if(i==-1) {
            return 0;
        }
        ++result;
        in_out_event->message.lsb(b);
        in_out_event->message.msb((uint8_t)i);    
        return result;
    case midi_message_type::program_change:
    case midi_message_type::channel_pressure:
    case midi_message_type::song_select:
        if(has_status) {
            i=in->getch();
            if(i==-1) {
                return 0;
            }
            ++result;
            in_out_event->message.value8 = (uint8_t)i;
            return result;
        } 
        in_out_event->message.value8 = b;
        
        return  result;
    case midi_message_type::system_exclusive:
        {
            uint8_t* psx = nullptr;
            size_t sxsz = 0;
            uint8_t buf[512];
            uint8_t b = 0;
            int i = 0;
            while(b!=0xF7) {
                if(0==in->read(&b,1)) {
                    if(nullptr!=psx) {
                        free(psx);
                    }
                    return 0;
                }
                ++result;
                buf[i++]=b;
                if(i==512) {
                    sxsz+=512;
                    if(psx==nullptr) {
                        psx=(uint8_t*)malloc(sxsz);
                        if(nullptr==psx) {
                            return 0;
                        }
                    } else {
                        psx=(uint8_t*)realloc(psx,sxsz);
                        if(nullptr==psx) {
                            return 0;
                        }
                    }
                    memcpy(psx+sxsz-512,buf,512);
                    i=0;
                }
            }
            if(i>0) {
                sxsz+=i;
                if(psx==nullptr) {
                    psx=(uint8_t*)malloc(sxsz);
                    if(nullptr==psx) {
                        return 0;
                    }
                } else {
                    psx=(uint8_t*)realloc(psx,sxsz);
                    if(nullptr==psx) {
                        return 0;
                    }
                }
                memcpy(psx+sxsz-i,buf,i);
            }
            in_out_event->message.sysex.data = psx;
            in_out_event->message.sysex.size = sxsz;
            return result;
        }
    case midi_message_type::reset:
        if(!is_file) {
            return result;
        }
        // this is a meta event
            i=in->getch();
            if(i==-1) {
                return 0;
            }
            ++result;
            in_out_event->message.meta.type = (uint8_t)i;
        {
            int32_t vl;
            size_t sz=midi_utility::decode_varlen(in,&vl);
            // re-encode it to fill our midi message
            midi_utility::encode_varlen(vl,in_out_event->message.meta.encoded_length);
            result+=sz;
            if(vl>0) {
                uint8_t* p = (uint8_t*)malloc(vl);
                if(nullptr==p) {
                    return 0;
                }
                if(vl!=in->read(p,vl)) {
                    free(p);
                    return 0;
                }
                result+=vl;
                in_out_event->message.meta.data=p;
                return result;
            }
            in_out_event->message.meta.data = nullptr;
            return result;
        }    
    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:
        return result;
    default:
        return result;
    }
}

特别糟糕,而且没有经过完全测试的是 sysex 解析部分。复杂之处在于我们不知道其大小。我们必须继续,直到找到结束 sysex 消息(0xF7)。我们一次读取最多 512 字节,并在需要时调整分配的空间大小,将新数据复制进来。唉。还应该注意的是,sysex 支持不完全符合规范。你应该能够将系统实时消息与 sysex 流混合,但这在此代码中不支持。

另一个值得庆幸的是稍微不那么复杂的部分是 MIDI 文件元消息。但是,由于长度存储为 varlen 值,我们必须从流中解码它,然后重新编码才能将其放回我们的消息中,因为我们已经用流的输入光标跳过了它。

现在我们拥有了从 MIDI 文件中提取轨道的事件的工具!不过,在这个阶段宣布胜利还为时过早。我们仍然需要将每个轨道的事件重新交错成一个 MIDI 流,然后最终通过 USB 输出。

交错是本文标题中包含“高级”的原因。我们基本上将直接从文件流中进行 MIDI 轨道混合,而不会在内存中加载比必需更多的内容。

基本思想如下:对于每个轨道,我们维护一个光标/上下文,其中包含输入位置和当前 midi_stream_event。一旦我们填充了每个上下文,我们就找到具有最接近的下一个绝对位置的事件的上下文。我们存储该事件的索引以备后用。

当我们检索消息时,我们只需返回上下文在该先前索引处指向的事件,然后解码该上下文/轨道的下一个事件。最后,我们重复查找绝对位置最接近的事件并存储其索引的过程。

reset() 方法将用初始值填充上下文,并且还允许我们在之后随时将光标返回到其起始位置。

sfx_result midi_file_source::reset() {
    if(m_stream==nullptr) {
        return sfx_result::invalid_state;
    }
    // reset the count of elapsed ticks
    m_elapsed = 0;
    // fill the contexts
    const size_t tsz = m_file.tracks_size;
    for(int i = 0;i<tsz;++i) {
        source_context* ctx = m_contexts+i;
        ctx->input_position = m_file.tracks[i].offset;
        // set the end flag in the case of a zero length track
        ctx->eos = !m_file.tracks[i].size;
        ctx->event.absolute = 0;
        ctx->event.delta = 0;
        ctx->event.message = midi_file_move(midi_message());
        // decode the first event
        if(!ctx->eos && ctx->input_position ==m_stream->seek(ctx->input_position)) {
            if(0!=midi_stream::decode_event(true,m_stream,&ctx->event)) {
                ctx->input_position = m_stream->seek(0,seek_origin::current);
            }    
        }  
    }
    // now go through the contexts and find the one with
    // the nearest absolute position.
    m_next_context = m_file.tracks_size;
    unsigned long long pos = (unsigned long long)-1;
    for(int i = 0;i<(int)tsz;++i) {   
        source_context* ctx = m_contexts+i;
        if(!ctx->eos) {
            if(ctx->event.absolute<pos) {
                m_next_context=i;
                pos = ctx->event.absolute;
            }
        }    
    }
    return m_next_context==m_file.tracks_size?
            sfx_result::end_of_stream:sfx_result::success;
}

上述代码的一个奇怪之处是 midi_file_move(),它强制编译器选择引用移动赋值运算符而不是复制赋值运算符。复制消息没有任何意义,而且只会浪费资源。基本上,它等同于 std::move<>,但在物联网设备上,我倾向于在我的库中限制使用 STL,原因如下。

有一个名为 read_next_event() 的辅助方法,我们在从上下文中提取下一个事件后使用它。它与上面的方法有些相似。

sfx_result midi_file_source::read_next_event() {
    size_t tsz = m_file.tracks_size;
    if(m_next_context==tsz) {
        return sfx_result::end_of_stream;
    }
    // find the next context we're 
    // pulling the message from
    source_context* ctx = m_contexts+m_next_context;
    if(ctx->eos) {
        return sfx_result::end_of_stream;
    }
    // seek to the current input position
    if(ctx->input_position!=m_stream->seek(ctx->input_position)) {
        return sfx_result::io_error;
    }
    // decode the next event
    size_t sz = midi_stream::decode_event(true,m_stream,&ctx->event);
    if(sz==0) {
        return sfx_result::invalid_format;
    }
    // increment the position
    ctx->input_position+=sz;
    // set the end of stream flag if we're there
    if(ctx->input_position-m_file.tracks[m_next_context].offset>=
            m_file.tracks[m_next_context].size) {
        ctx->eos = true;
    }
    // find the context with the nearest absolutely positioned
    // event and store the index of it for later
    bool done = true;
    m_next_context = tsz;
    unsigned long long pos = (unsigned long long)-1;
    for(int i = 0;i<(int)tsz;++i) {   
        ctx = m_contexts+i;
        if(!ctx->eos) {
            if(ctx->event.message.status!=0 && 
                    ctx->event.absolute<pos) {
                m_next_context=i;
                pos = ctx->event.absolute;
                done = false;
            }
        }    
    }
    return done?sfx_result::end_of_stream:
                sfx_result::success;
}

接着是 receive(),它从我们的上下文中提取下一个事件并返回它,通过从事件的绝对位置减去当前的 elapsed() 计数来重新计算 delta。

sfx_result midi_file_source::receive(midi_event* out_event) {
    if(m_stream==nullptr) {
        return sfx_result::invalid_state;
    }
    if(m_next_context==m_file.tracks_size) {
        return sfx_result::end_of_stream;
    }
    
    source_context* ctx = m_contexts+m_next_context;
    if(ctx->eos) {
        return sfx_result::end_of_stream;
    }
    out_event->delta = (int32_t)ctx->event.absolute-m_elapsed;
    // the midi_file_move will cause these values
    // to potentially be zeroed so we preserve 
    // them:
    uint8_t status = ctx->event.message.status;
    uint8_t type = ctx->event.message.meta.type;
    out_event->message = midi_file_move(ctx->event.message);
    // set a running status byte
    ctx->event.message.status = status; 
    // set the meta type
    ctx->event.message.meta.type = type; 
    // don't need anything else
    
    // advance the elapsed ticks
    m_elapsed = ctx->event.absolute;
    
    // refill our contexts
    sfx_result r =read_next_event();
    if(r==sfx_result::end_of_stream) {
        return sfx_result::success;
    }
    return r;
}

请注意,我们在这里返回的是 midi_event 而不是 midi_stream_event。我们不再需要绝对位置。

通过调用 receive() 直到返回 sfx_result::end_of_stream,我们将能够按顺序获取事件,而不管它们位于哪个轨道。delta 值也会被重新计算。

没有地方发送消息就没什么意思了。这就是我们使用新款 ESP32S3 的 USB 功能的地方。基本上,我们将发布一个 MIDI USB 设备,并在特定时间向其馈送 MIDI 消息。我们将使用一个 midi_clock,我不会在此处涵盖其内部工作原理,但它会根据速度和时间基处理时序,并在每个 MIDI tick 调用我们。如果你的 timebase 是 24,你每“拍”(技术上说是四分音符)会有 24 个 tick。当 clock 能够做到时,它会帮助我们在每个 tick 发生时调用我们。clock 是协作式多任务的,因此需要进行泵送。如果它没有被充分泵送,它将报告它错过了多少 tick,但我们不必关心。

但在我们开始之前,让我们介绍一下馈送消息的部分。应注意,我们不会立即将音符发送到 USB,因为它们需要进行时序控制。我们维护一个 std::queue<> 来存储 midi_stream_event 事件,这些事件包含要播放的下一个事件及其相对于文件的绝对位置。这些事件在 clock 的 tick 回调中被检索,届时它们的 MIDI 消息会被打包成二进制格式,并在时间到了之后发送到 USB。

我们继续读取,直到收到 sfx_result::end_of_stream,此时我们调用源的 reset() 并重复。我们一直这样做,直到出现错误,所以可能会永远持续下去。

void setup() {
    // an midi event source over a midi file
    midi_file_source msrc;
    // a midi clock
    midi_clock mclk;
    // a queue used to hold pending events
    midi_queue mqueue;
    // the state for the callback
    midi_clock_state mstate;
    mstate.clock = &mclk;
    mstate.queue = &mqueue;
    mstate.source = &msrc;
    Serial.begin(115200);
    // set the tick callback
    mclk.tick_callback(tick_callback,&mstate);
    SPIFFS.begin();
    // necessary boilerplate
    // to get this to work on an S3
    // instead of an S2:
    midi.setBaseEP(3);
    midi.begin();
    midi.setBaseEP(3);
    Serial.println("10 seconds to set up equipment starting now.");
    // must delay at least 1000!
    delay(10000);

    File file = SPIFFS.open("/indaclub.mid", "rb");
    // we use streams as a cross platform way to wrap platform dependent filesystem stuff
    file_stream fstm(file);
    // open the midi file source
    sfx_result r = midi_file_source::open(&fstm, &msrc);
    if (sfx_result::success != r) {
        Serial.printf("Error opening file: %d\n", (int)r);
        while (true)
            ;
    }
    // set the clock's timebase
    mclk.timebase(msrc.file().timebase);
    // start the clock
    mclk.start();
    // go forever
    while (true) {
        // the midi event
        midi_event e;
        // don't let the queue get overly big, there's no reason to
        if (mqueue.size() >= 16) {
            // just pump the clock
            mclk.update();
            continue;
        }
        // get the next event
        sfx_result r = msrc.receive(&e);
        if (r != sfx_result::success) {
            if (r == sfx_result::end_of_stream) {
                // pump the queue until out of messages
                while(mqueue.size()>0) {
                    mclk.update();
                }
                // reset the clock 
                // and the source
                mclk.stop();
                msrc.reset();
                mclk.start();
                continue;                
            }
            Serial.printf("Error receiving message: %d\n", (int)r);
            // exit
            break;
        } else {
            dump_midi(e.message);
            // add the event to the queue
            mqueue.push({(unsigned long long)msrc.elapsed(), e.delta, e.message});
            // pump the clock
            mclk.update();
        }
    }
}

现在我们已经将消息馈送到队列中,我们应该探索 tick 回调。这里的想法是播放队列中绝对位置小于或等于 clock 的已用 tick 计数的任何消息。因此,我们循环,当我们从队列中提取一条消息并发送它时,我们必须将其打包成 MIDI 线格式,然后使用低级调用来写入它,因为 ESPTinyUSB 的 MIDI 没有完全实现,而且它的实现方式并不真正符合我们想要使用它的方式。

void tick_callback(uint32_t pending, unsigned long long elapsed, void* state) {
    uint8_t buf[3];
    midi_clock_state* st = (midi_clock_state*)state;
    while (true) {
        // TODO: since I haven't made a usb_midi_output_device
        // we have to do this manually
        // events on the queue?
        if (st->queue->size()) {
            // peek the next one
            const midi_stream_event& event = st->queue->front();
            // is it ready to be played
            if (event.absolute <= elapsed) {
                // special handing for midi meta file tempo events
                if (event.message.type() == midi_message_type::meta_event && 
                          event.message.meta.type == 0x51) {
                    int32_t mt = (event.message.meta.data[0] << 16) | 
                    (event.message.meta.data[1] << 8) | event.message.meta.data[2];
                    // update the clock microtempo
                    st->clock->microtempo(mt);
                }
                if (event.message.status != 0xFF || 
                          event.message.meta.data == nullptr) {
                    // send a sysex message
                    if (event.message.type() == midi_message_type::system_exclusive && 
                              event.message.sysex.data != nullptr) {
                        uint8_t* p = (uint8_t*)malloc(event.message.sysex.size + 1);
                        if (p != nullptr) {
                            *p = event.message.status;
                            memcpy(p + 1, event.message.sysex.data, 
                                          event.message.sysex.size);
                            tud_midi_stream_write(0, p, event.message.sysex.size + 1);
                            free(p);
                        }
                    } else {
                        // send a regular message
                        // build a buffer and send it using raw midi
                        buf[0] = event.message.status;
                        if ((int)event.message.type() <= 
                            (int)midi_message_type::control_change) {
                            switch (event.message.wire_size()) {
                                case 1:
                                    //tud_midi_stream_write(event.message.channel(), 
                                    //buf, 1);
                                    tud_midi_stream_write(0, buf, 1);
                                    break;
                                case 2:
                                    buf[1] = event.message.value8;
                                    //tud_midi_stream_write(event.message.channel(), 
                                    //buf, 2);
                                    tud_midi_stream_write(0, buf, 2);
                                    break;
                                case 3:
                                    buf[1] = event.message.lsb();
                                    buf[2] = event.message.msb();
                                    //tud_midi_stream_write(event.message.channel(), 
                                    //buf, 3);
                                    tud_midi_stream_write(0, buf, 3);
                                    break;
                                default:
                                    break;
                            }
                        } else {
                            switch (event.message.wire_size()) {
                                case 1:
                                    tud_midi_stream_write(0, buf, 1);
                                    break;
                                case 2:
                                    buf[1] = event.message.value8;
                                    tud_midi_stream_write(0, buf, 2);
                                    break;
                                case 3:
                                    buf[1] = event.message.lsb();
                                    buf[2] = event.message.msb();
                                    tud_midi_stream_write(0, buf, 3);
                                    break;
                                default:
                                    break;
                            }
                        }
                    }
                }
                // ensure the message gets destroyed
                // (necessary? I don't think so, but I'd rather not leak)
                event.message.~midi_message();
                // remove the message
                st->queue->pop();
            } else {
                break;
            }
        } else {
            break;
        }
    }
}

请原谅换行。它嵌套得很深。总之,这就是我们所做的一切。

如果你想听到它,请启动 MIDI-OX 并转到 **Options**|**MIDI Devices**,然后从你的输入中添加“**MIDI Class**”。

历史

  • 2022年5月9日 - 首次提交
© . All rights reserved.