Midi文件采用二进制格式存储音乐相关信息,和常见的音频文件不同,它不直接存储声音波形,而是存储音符、音色、节奏等指令信息。Midi文件中大量使用变长数量VLQ编码来节省存储空间,这种编码方式可以用1到4个字节表示不同范围的数值,是解析Midi文件必须掌握的核心知识点。

Midi文件基础结构
Midi文件整体由文件头块和若干个轨道块组成,每个块都有固定的格式:
- 文件头块:固定以
MThd开头,后面跟着4字节的长度值(固定为6)和3个16位数据,分别是文件格式、轨道数量、时间单位 - 轨道块:固定以
MTrk开头,后面跟着4字节的长度值表示轨道数据的总字节数,剩下的就是事件数据,事件数据中的时间偏移量全部采用VLQ编码
VLQ编码规则
VLQ(Variable Length Quantity)变长数量编码的核心规则如下:
- 每个字节的最高位(第7位)是标志位,1表示后面还有后续字节,0表示这是最后一个字节
- 每个字节的低7位(第0到6位)是有效数据位
- 解码时需要将每个字节的低7位按顺序拼接,得到最终的数值
比如数值0x3F(十进制63)只需要1个字节:0x3F(最高位0,低7位是00111111)。数值0x80(十进制128)需要2个字节:第一个字节0x81(最高位1,低7位是0000001),第二个字节0x00(最高位0,低7位是0000000),拼接后得到0000001 0000000,即0x80。
C++实现VLQ解码
VLQ解码的步骤很清晰:从输入流中逐个读取字节,每次取低7位左移对应位数后累加,直到读取到最高位为0的字节为止。下面是完整的实现代码:
#include <iostream>
#include <fstream>
#include <vector>
#include <cstdint>
// 从文件流中读取一个VLQ编码的数值,返回读取的字节数,结果存到result中
// 如果读取失败(比如到文件末尾还没有结束)返回-1
int readVLQ(std::ifstream& file, uint32_t& result) {
result = 0;
int byteCount = 0;
uint8_t byte;
do {
// 读取一个字节
if (!file.read(reinterpret_cast<char*>(&byte), sizeof(byte))) {
return -1;
}
byteCount++;
// 取低7位,左移7*(byteCount-1)位后累加
result = (result << 7) | (byte & 0x7F);
} while (byte & 0x80); // 最高位为1则继续读取
return byteCount;
}
结合Midi文件读取的完整示例
下面代码实现读取Midi文件头部,并解析第一个轨道中的第一个VLQ时间偏移量:
// 读取Midi文件头
bool readMidiHeader(std::ifstream& file, uint16_t& format, uint16_t& trackCount, uint16_t& division) {
char headerTag[4];
// 读取文件头标识MThd
if (!file.read(headerTag, 4) || strncmp(headerTag, "MThd", 4) != 0) {
std::cerr << "不是合法的Midi文件" << std::endl;
return false;
}
// 读取头部长度,Midi文件采用大端字节序
uint32_t headerLen;
file.read(reinterpret_cast<char*>(&headerLen), 4);
headerLen = __builtin_bswap32(headerLen); // 大端转小端,不同编译器可能有不同的大端转换函数
if (headerLen != 6) {
std::cerr << "Midi文件头长度异常" << std::endl;
return false;
}
// 读取文件格式
file.read(reinterpret_cast<char*>(&format), 2);
format = __builtin_bswap16(format);
// 读取轨道数量
file.read(reinterpret_cast<char*>(&trackCount), 2);
trackCount = __builtin_bswap16(trackCount);
// 读取时间单位
file.read(reinterpret_cast<char*>(&division), 2);
division = __builtin_bswap16(division);
return true;
}
int main() {
std::ifstream midiFile("test.mid", std::ios::binary);
if (!midiFile.is_open()) {
std::cerr << "无法打开Midi文件" << std::endl;
return 1;
}
uint16_t format, trackCount, division;
if (!readMidiHeader(midiFile, format, trackCount, division)) {
return 1;
}
std::cout << "Midi文件格式: " << format << std::endl;
std::cout << "轨道数量: " << trackCount << std::endl;
std::cout << "时间单位: " << division << std::endl;
// 读取第一个轨道的标识和长度
char trackTag[4];
if (!midiFile.read(trackTag, 4) || strncmp(trackTag, "MTrk", 4) != 0) {
std::cerr << "轨道格式异常" << std::endl;
return 1;
}
uint32_t trackLen;
midiFile.read(reinterpret_cast<char*>(&trackLen), 4);
trackLen = __builtin_bswap32(trackLen);
// 解析轨道中的第一个VLQ时间偏移量
uint32_t deltaTime;
int vlqBytes = readVLQ(midiFile, deltaTime);
if (vlqBytes == -1) {
std::cerr << "VLQ解码失败" << std::endl;
return 1;
}
std::cout << "第一个事件的时间偏移量: " << deltaTime << std::endl;
std::cout << "该VLQ编码占用的字节数: " << vlqBytes << std::endl;
midiFile.close();
return 0;
}
注意事项
- Midi文件所有多字节数值都采用大端字节序,读取后需要转换成本地字节序再使用
- VLQ编码最多使用4个字节,解码时可以增加校验逻辑,避免异常文件导致的无限循环
- 轨道数据中的事件类型很多,读取VLQ时间偏移量后还需要根据后续字节判断事件类型,再做对应的解析
VLQ解码是Midi文件解析的基础,掌握这个逻辑后,再扩展其他事件类型的解析就会轻松很多,开发者可以在此基础上逐步完善整个Midi文件解析器的功能。