节后开工第一天:手写轻量级 TypeScript 标准 MIDI 协议解析与事件流分发器

发布时间:2026/10/9 7:39:35
节后开工第一天:手写轻量级 TypeScript 标准 MIDI 协议解析与事件流分发器
国庆长假刚过回到工位上的第一件事不是急着去开那些漫无目的的收心会而是把假期巡演路上构思的一个前端音频底座给实现出来。在网页端做音乐交互或者声效驱动的大促互动组件时我们经常需要处理 MIDIMusical Instrument Digital Interface数据。大部分开源社区的 MIDI 解析库都有点历史包袱要么是用古老的 ES5 原型链拼凑动辄带着 Node.js 的 Buffer polyfill打包体积轻松突破 50KB要么就是把文件解析和播放器强耦合完全没考虑浏览器主线程对微秒级调度事件分发的要求。我们只需要一个极致轻量、纯 TypeScript 编写、零第三方依赖的标准 MIDI 文件SMF, Standard MIDI File二进制解析器并能够以响应式事件流的形式向 Web Audio 或合成器精准推送 Note On/Off 消息。MIDI 文件的二进制解剖标准 MIDI 文件.mid采用的是基于 Chunk数据块的二进制格式包含一个头块Header Chunk和一个或多个轨道块Track Chunk。每个块都以 4 字节的 ASCII 标识符开头紧跟着 4 字节的 Big-Endian 大端序长度值Header Chunk (MThd)固定 14 字节长度。核心参数包括格式类型Format 0 单轨、Format 1 多轨同步、Format 2 多轨异步、轨道数量Tracks Count以及时间除数Division / PPQ每四分音符的脉冲滴答数。Track Chunk (MTrk)包含一连串的 MIDI 事件Event。每个事件由一个变长数量Variable-Length Quantity, VLQ表示的 Delta-Time时间增量和一个事件状态字节Status Byte驱动。其中最考验解析细节的有两个地方变长数量VLQ的字节解包以及运行状态Running Status的隐式继承。变长数量VLQ机制在 MIDI 规范中为了省带宽时间增量 Delta-Time 使用 7 位编码的变长字节如果字节的最高位MSB为 1说明后续字节依然属于当前数值如果最高位为 0则表示该数值的最后一个字节。一个 4 字节的 VLQ 最多能表达 28 位的整型数值。运行状态Running Status当连续发生同一种类型的 MIDI 消息例如钢琴滚奏产生的大量Note On时MIDI 允许省略状态字节直接复用上一个事件的状态码。很多不严谨的手写解析器遇到带有 Running Status 的 MIDI 文件会直接抛出非法字节异常导致整个音频引擎崩溃。纯 TypeScript 解析与事件抽象基于现代 ArrayBuffer 和 DataView我们可以在纯前端内存里实现零拷贝的流式切片。以下是完整且类型完备的解析实现export interface MidiHeader { format: 0 | 1 | 2; trackCount: number; ticksPerBeat: number; // PPQ (Pulses Per Quarter Note) } export type MidiEventType | noteOff | noteOn | polyphonicAftertouch | controlChange | programChange | channelAftertouch | pitchBend | meta | sysex; export interface BaseMidiEvent { deltaTime: number; // 距离上一个事件的 Tick 差值 absoluteTicks: number; // 绝对时间戳 (Ticks) type: MidiEventType; channel?: number; // 0-15 } export interface NoteEvent extends BaseMidiEvent { type: noteOn | noteOff; channel: number; noteNumber: number; // 0-127 (中央 C 为 60) velocity: number; // 0-127 (若 noteOn 的 velocity 为 0等价于 noteOff) } export interface ControlChangeEvent extends BaseMidiEvent { type: controlChange; channel: number; controller: number; // 控制器编号 (如 7 为主音量, 64 为延音踏板) value: number; // 0-127 } export interface MetaEvent extends BaseMidiEvent { type: meta; metaType: number; // 例如 0x51 表示 Tempo 设置, 0x58 表示节拍记号 data: Uint8Array; } export type ParsedMidiEvent NoteEvent | ControlChangeEvent | MetaEvent | BaseMidiEvent; export class BinaryStreamReader { private view: DataView; public cursor: number 0; constructor(buffer: ArrayBuffer) { this.view new DataView(buffer); } public get eof(): boolean { return this.cursor this.view.byteLength; } public readUint8(): number { return this.view.getUint8(this.cursor); } public readUint16(): number { const val this.view.getUint16(this.cursor, false); // Big-Endian this.cursor 2; return val; } public readUint32(): number { const val this.view.getUint32(this.cursor, false); // Big-Endian this.cursor 4; return val; } public readString(length: number): string { let result ; for (let i 0; i length; i) { result String.fromCharCode(this.readUint8()); } return result; } public readBytes(length: number): Uint8Array { const bytes new Uint8Array(this.view.buffer, this.view.byteOffset this.cursor, length); this.cursor length; return bytes; } /** * 解码 MIDI 规范中的变长数量 (VLQ) */ public readVarInt(): number { let value 0; let byte 0; do { byte this.readUint8(); value (value 7) | (byte 0x7f); } while (byte 0x80); return value; } }有了高性能的底座读取器后就可以解析具体的 Track 轨道数据。针对 Running Status我们维护一个lastStatusByte上下文变量export class StandardMidiParser { public static parse(buffer: ArrayBuffer): { header: MidiHeader; tracks: ParsedMidiEvent[][] } { const reader new BinaryStreamReader(buffer); // 1. 解析 Header Chunk const headerChunkType reader.readString(4); if (headerChunkType ! MThd) { throw new Error(非法 MIDI 文件头标识: ${headerChunkType}期望 MThd); } const headerLength reader.readUint32(); if (headerLength 6) { throw new Error(Header 块长度异常: ${headerLength}); } const format reader.readUint16() as 0 | 1 | 2; const trackCount reader.readUint16(); const timeDivision reader.readUint16(); // 如果最高位为 1 表示 SMPTE 帧率编码这里默认处理最通用的 PPQ if (timeDivision 0x8000) { throw new Error(暂不支持 SMPTE 格式时间除数); } const ticksPerBeat timeDivision; // 跳过多余的头信息字节 (容错处理) if (headerLength 6) { reader.cursor (headerLength - 6); } const header: MidiHeader { format, trackCount, ticksPerBeat }; const tracks: ParsedMidiEvent[][] []; // 2. 解析每个 Track Chunk for (let t 0; t trackCount; t) { if (reader.eof) break; const trackChunkType reader.readString(4); if (trackChunkType ! MTrk) { throw new Error(非法轨道标识: ${trackChunkType}轨道索引: ${t}); } const trackLength reader.readUint32(); const trackEndOffset reader.cursor trackLength; const trackEvents: ParsedMidiEvent[] []; let currentAbsoluteTicks 0; let runningStatus: number | null null; while (reader.cursor trackEndOffset) { const deltaTime reader.readVarInt(); currentAbsoluteTicks deltaTime; let statusByte reader.readUint8(); // 处理 Running Status: 若当前字节最高位不是 1则复用上一个状态码 if ((statusByte 0x80) 0) { if (runningStatus null) { throw new Error(在无有效运行状态时遇到裸数据字节: 0x${statusByte.toString(16)}); } reader.cursor--; // 回退一个字节将该字节当作第一位数据处理 statusByte runningStatus; } else { runningStatus statusByte; } // Meta 事件 if (statusByte 0xff) { const metaType reader.readUint8(); const metaLength reader.readVarInt(); const metaData reader.readBytes(metaLength); trackEvents.push({ deltaTime, absoluteTicks: currentAbsoluteTicks, type: meta, metaType, data: metaData, }); // 轨道结束标识 (End of Track) if (metaType 0x2f) { break; } continue; } // SysEx 系统专用消息 if (statusByte 0xf0 || statusByte 0xf7) { const sysexLength reader.readVarInt(); reader.readBytes(sysexLength); continue; } // 标准通道消息 (Channel Voice Messages) const messageType statusByte 4; const channel statusByte 0x0f; switch (messageType) { case 0x8: { // Note Off const noteNumber reader.readUint8(); const velocity reader.readUint8(); trackEvents.push({ deltaTime, absoluteTicks: currentAbsoluteTicks, type: noteOff, channel, noteNumber, velocity, }); break; } case 0x9: { // Note On const noteNumber reader.readUint8(); const velocity reader.readUint8(); // 在 MIDI 协议中velocity 为 0 的 Note On 等同于 Note Off const effectiveType velocity 0 ? noteOff : noteOn; trackEvents.push({ deltaTime, absoluteTicks: currentAbsoluteTicks, type: effectiveType, channel, noteNumber, velocity, }); break; } case 0xb: { // Control Change const controller reader.readUint8(); const value reader.readUint8(); trackEvents.push({ deltaTime, absoluteTicks: currentAbsoluteTicks, type: controlChange, channel, controller, value, }); break; } case 0xc: { // Program Change (音色切换) const program reader.readUint8(); trackEvents.push({ deltaTime, absoluteTicks: currentAbsoluteTicks, type: programChange, channel, }); break; } case 0xe: { // Pitch Bend reader.readUint8(); reader.readUint8(); break; } default: // 兜底处理大部分未命中消息包含两个数据字节 reader.readUint8(); reader.readUint8(); break; } } tracks.push(trackEvents); // 容错校准若因数据不规范导致游标未对齐强行跳到该轨道声明的结尾 reader.cursor trackEndOffset; } return { header, tracks }; } }响应式事件调度与时钟对齐将二进制解析为原始数组只是第一步。在真实的 Web 页面中如果要驱动 Synth 音源发声或者联动 Canvas 粒子爆炸必须将抽象的absoluteTicks转化为基于真实物理时间的毫秒或者秒Seconds。MIDI 文件中默认速度是 120 BPM即 500,000 微秒/拍。如果在 Meta 事件中遇到了0x51Set Tempo就需要更新微秒与 Tick 的换算系数。export interface ScheduledMidiNote { timeInSeconds: number; durationInSeconds: number; pitch: number; velocity: number; channel: number; } export class MidiEventDispatcher { /** * 将解析出来的多轨事件平铺并按时间戳排序转换为物理秒数轴 */ public static flattenToTimeline( tracks: ParsedMidiEvent[][], ppq: number ): ScheduledMidiNote[] { // 默认速度120 BPM (500000 微秒/拍) let currentTempoMicros 500000; const notes: ScheduledMidiNote[] []; // 活跃中的音符记录表Key 为 channel_pitch const activeNoteMap new Mapstring, { startSec: number; velocity: number }(); // 合并所有轨道的事件流 const allEvents tracks.flat().sort((a, b) a.absoluteTicks - b.absoluteTicks); let lastTick 0; let accumulatedTimeSec 0; for (const ev of allEvents) { const deltaTicks ev.absoluteTicks - lastTick; if (deltaTicks 0) { // 秒数增量 (增量Ticks / PPQ) * (微秒每拍 / 1,000,000) const secondsPerTick (currentTempoMicros / 1000000) / ppq; accumulatedTimeSec deltaTicks * secondsPerTick; lastTick ev.absoluteTicks; } if (ev.type meta (ev as MetaEvent).metaType 0x51) { const meta ev as MetaEvent; // 3 字节表示的大端序微秒数 currentTempoMicros (meta.data[0] 16) | (meta.data[1] 8) | meta.data[2]; continue; } if (ev.type noteOn) { const note ev as NoteEvent; const key ${note.channel}_${note.noteNumber}; activeNoteMap.set(key, { startSec: accumulatedTimeSec, velocity: note.velocity, }); } else if (ev.type noteOff) { const note ev as NoteEvent; const key ${note.channel}_${note.noteNumber}; const active activeNoteMap.get(key); if (active) { notes.push({ channel: note.channel, pitch: note.noteNumber, velocity: active.velocity, timeInSeconds: active.startSec, durationInSeconds: Math.max(0.05, accumulatedTimeSec - active.startSec), }); activeNoteMap.delete(key); } } } return notes.sort((a, b) a.timeInSeconds - b.timeInSeconds); } }避坑指南与前端工程考量不要在主线程同步解析大体积 MIDI 文件虽然普通 MIDI 只有十几到几十 KB但交响乐工程或由 AI 生成的超高密度滑音 MIDI 文件可能包含数十万个 Control Change 事件。解析超过 1MB 的二进制文件时务必移入 Web Worker 中执行利用postMessage(notes, [notes.buffer])进行零拷贝传输。Velocity 为 0 的 Note On 兼容早期的 Roland 和 Yamaha 键盘硬件在释放琴键时为了追求效率往往不发 0x80Note Off而是发一个力度为 0 的 0x90Note On。如果代码里直接把0x90判定为发声遇到这种硬件导出的文件就会出现音符无限挂起、永不释放的严重 Bug。精准调度不可用setTimeout前端如果要把解析出的事件派发给 Web Audio API必须直接依赖AudioContext.currentTime。使用setTimeout或setInterval在浏览器高负载或切到后台标签页时会有几十毫秒的随机抖动足以彻底毁掉整个鼓点或贝斯旋律的律动。从巡演现场的硬件合成器到工位上的浏览器沙盒协议底层的二进制规范其实是完全共通的。代码的健壮性不在于用了多么庞大的类库而在于对每一位字节、每一个状态标志的敬畏与精准把控。