1. 从浏览器里传出一段钢琴声,这件事比想象中有意思
第一次动念头做Euphony这个 Web 端 MIDI 播放器与可视化工具,起因特别朴素:我在整理一批老旧的 MIDI 编曲素材时,发现本地播放器要么界面停留在上个时代,要么可视化做得像心电图,完全看不出声部之间的关系。更麻烦的是,我想把这些曲子分享给几个不在同一城市的朋友听,让他们看到同一段旋律的声部走向,结果对方电脑上装的东西五花八门,最后演变成"你先装个软件,我传个文件给你"的尴尬循环。于是我琢磨,能不能干脆把解析、播放、可视化三件事全部塞进浏览器,打开一个链接就能听、能看、能拖动进度条。
这个想法落地之后,就是Euphony。它是一个纯前端项目,不依赖任何服务端渲染,不做音频转码上传,核心目标有三个:把标准 MIDI 文件在浏览器里解析成可调度的音符事件流,用 Web Audio 把它们实时合成出来,再用 Canvas 实时绘制成能看懂的可视化画面。说白了,它解决的是"MIDI 文件看得见却听不懂、听得见却看不懂"这两个割裂的问题。适合谁来参考?如果你写过一点 JavaScript,对音频或图形有点好奇,这篇文章里的解析逻辑、调度方案、渲染思路都能直接拿去用;如果你只是想要一个能在网页里放 MIDI 的工具,那也能从踩坑记录里少走不少弯路。
我特别想强调一点:很多人一听到"Web MIDI"就以为浏览器自带 MIDI 播放能力,这是个挺常见的误解。Web MIDI API 的设计初衷是让你连接外部 MIDI 键盘、控制器这类硬件设备,读取它们发出的实时消息,它并不负责帮你解析磁盘上的.mid文件,更不负责把这些消息变成声音。这两件事得自己干。想清楚这个边界,后面的架构才不会一开始就跑偏。
2. 拆开一个 MIDI 文件:解析层到底要处理什么
2.1 MIDI 文件格式的核心结构
标准 MIDI 文件(SMF)本质是一个二进制容器,结构比我最初想象的规整得多。整个文件由若干"块"(chunk)拼成,每个块开头是 4 字节的类型标识,接着 4 字节的大端无符号整数表示数据长度,然后才是数据本体。第一个块一定是MThd,长度固定为 6 字节,里面装着格式类型(0、1 或 2)、轨道数量、以及每个四分音符对应的 tick 数——也就是常说的 PPQ(Pulses Per Quarter note)。后面的块全是MTrk,一个块对应一条轨道,长度不固定。
格式 0 意味着所有声部挤在一条轨道里,格式 1 是常见的多轨结构,第一条轨道通常只放 tempo、拍号、调号这类元事件,后面每条轨道对应一个乐器声部,格式 2 比较少见,各轨道彼此独立、时间轴不共享。做播放器的话,格式 0 和 1 必须支持,格式 2 我建议先放着,因为它的语义和"同时播放"的直觉不一样,处理起来容易出错。
我读文件时用的是FileReader的readAsArrayBuffer,拿到ArrayBuffer之后套一个DataView逐字节读。这里有个小选择值得说:为什么不用Uint8Array直接索引?因为 MIDI 里到处都是多字节大端整数,DataView的getUint32(offset, false)一行就能读出来,比自己移位拼接要少出错。代价是DataView的读写比定型数组慢一点点,但对于一个几 MB 的 MIDI 文件来说,这点开销完全可以忽略,代码可读性明显更重要。
function readChunkHeader(view, offset) { const type = String.fromCharCode( view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3) ); const length = view.getUint32(offset + 4, false); return { type, length, dataStart: offset + 8 }; }解析MThd的 6 个字节时,前两个字节是格式,接着两个是轨道数,最后两个是 division。这里有个坑:division 的最高位如果被置为 1,表示用的是 SMPTE 时间码而不是 PPQ,低字节是每帧的 tick 数,高字节是负的帧率编码。绝大多数流行 MIDI 文件都用 PPQ,但我第一次写解析器时就遇到过 SMPTE 的样本,直接按 PPQ 算会导致整首曲子的速度完全错乱。
2.2 可变长度量与毫秒换算
MIDI 轨道里每个事件前面都有一个 delta time,表示"距离上一个事件过了多少 tick"。这个数字的存储方式叫可变长度量(VLQ),规则是:每个字节的最高位是延续标志,为 1 说明后面还有字节,为 0 说明这是最后一个字节;剩下的 7 位参与数值拼接,先出现的是高位。一个 VLQ 最多 4 字节,能表示到 0x0FFFFFFF。
我第一次手写这段逻辑时犯了个经典错误——把字节顺序搞反了,导致长的 delta time 全部算错。正确的做法是循环读取,每次把当前累积值左移 7 位再加上新读到的低 7 位:
function readVarLen(view, offset) { let value = 0; let byte; do { byte = view.getUint8(offset++); value = (value << 7) | (byte & 0x7f); } while (byte & 0x80); return { value, next: offset }; }拿到 tick 距离之后,要把它换算成秒。换算依赖 tempo 事件,它属于元事件,状态字节是0xFF,类型是0x51,后面跟着 3 字节的微秒数,表示一个四分音符持续多久。默认值是 500000 微秒,也就是 120 BPM。换算公式是这样的:一小节假定的 tick 数是 PPQ,那么每个 tick 的秒数等于tempo / 1000000 / ppq。举个具体的例子,PPQ 是 480、tempo 是 500000 时,单 tick 时长是500000 / 1e6 / 480 ≈ 0.001042秒,也就是说一个四分音符(480 tick)正好 0.5 秒,对应 120 BPM,对得上。
麻烦的地方在于 tempo 会在曲子里变化。你不能全局算一个系数就完事,必须在遍历事件时维护"当前 tick 位置"和"当前 tempo",遇到 tempo 事件就更新系数,同时把当前 tick 位置对应的时间点记下来,之后的 tick 都按新系数累积。我一开始偷懒用整曲平均速度,结果遇到渐慢段落,音符全部提前跑掉,听起来像卡带快进。
2.3 事件流的整理与轨道合并
解析完所有轨道之后,我得到的是若干条独立的事件数组,每条里的事件都带绝对时间(我已经把 delta 累加过了)。播放器真正需要的是一个全局按时间排序的事件序列,所以下一步是把多轨合并。合并的方式是简单的多路归并——每次从各轨道的当前头部取出时间最早的那个事件,推进该轨道的指针,直到所有轨道取完。这个操作是 O(n log k),k 是轨道数,实际轨道数一般不超过 16,性能完全不是问题。
合并的时候要处理几个特殊事件。0x51的 tempo 我保留在时间轴上,因为调度器需要知道当前速度;0x58拍号和0x59调号对播放没影响,但可视化时可以用来画小节线,所以我选择保留;0x2F是轨道结束标记,必须识别,否则解析器会读到块尾之后的垃圾数据;0x03是轨道名,我把它提取出来作为声部的显示标签,可视化图例里挺好用。
还有一个必须处理的细节:note on 事件(状态0x9n)的 velocity 如果为 0,语义上等价于 note off(0x8n)。很多导出工具会这么省字节。如果解析时不做转换,音符就永远不会结束,播放时会听到一串永不衰减的长音叠在一起,非常刺耳。我在调试早期就被这个坑折腾了半个下午,最后是在事件归一化阶段统一把velocity === 0的 note on 改写成 note off 才解决。
3. 让声音真正响起来:合成方案怎么选
3.1 振荡器合成和采样播放的取舍
Web Audio 里让 MIDI 发声,主流有两种路子。第一种是纯合成:用OscillatorNode产生波形,通过调整波形类型、滤波器、包络去逼近不同乐器。第二种是采样:预先加载一组音频样本,播放时根据音高调整playbackRate。
我最终选的是"合成为主、采样为辅"的混合方案。原因很实际:纯采样要覆盖 128 个音高、十几种乐器,素材包动辄几十 MB,一个标榜轻量的 Web 工具让人等半天下载音频,体验直接崩掉。纯振荡器又太"电子味",钢琴听起来像方波蜂鸣。折中做法是用PeriodicWave自定义谐波分量,为几类常见音色(钢琴、弦乐、贝斯、拨弦)各调一组谐波系数,再用滤波器做音色塑形,代价只有几十行代码,体积几乎为零。
具体怎么调?createPeriodicWave接收两个Float32Array,一个是实部(余弦分量),一个是虚部(正弦分量),索引就是谐波次数。我拿钢琴举例,基频给满,2 次谐波给 0.5,3 次给 0.25,4 次给 0.12,高次逐渐衰减,整体听起来就比较接近敲击类音色。弦乐则相反,奇次谐波保留得多一些,配合轻微的频率抖动(detune)会更"厚"。
function makePianoWave(ctx) { const n = 16; const real = new Float32Array(n); const imag = new Float32Array(n); imag[1] = 1.0; imag[2] = 0.5; imag[3] = 0.25; imag[4] = 0.12; imag[5] = 0.06; return ctx.createPeriodicWave(real, imag, { disableNormalization: false }); }需要提醒的是,disableNormalization我一般保持默认的false,让浏览器帮忙做归一化,否则多个谐波叠起来很容易超过 1.0 的幅度,直接爆音。第一次没注意这个参数,弹出的和弦刺得我赶紧把音量关了。
3.2 音频节点链的编排
每个音符发声时,我搭的节点链是:OscillatorNode → BiquadFilterNode → GainNode → 主 GainNode → destination。振荡器负责基频,滤波器负责音色亮度(低通截止频率随音高上移),第一个增益节点负责 ADSR 包络,主增益负责整体音量和总线压缩前的电平控制。
ADSR 的实现依赖AudioParam的时间轴方法。攻击段用linearRampToValueAtTime,衰减和延音用setTargetAtTime,释放段用exponentialRampToValueAtTime。这里有个必须记住的规则:指数曲线不能把目标值设成 0,因为数学上永远到不了,会抛异常。所以释放段的终点我统一用一个极小值,比如 0.0001,再在之后手动stop()掉振荡器。
const now = ctx.currentTime; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(peak, now + attack); gain.gain.setTargetAtTime(sustainLevel, now + attack, decay / 3); // 释放阶段 gain.gain.cancelScheduledValues(releaseStart); gain.gain.setValueAtTime(gain.gain.value, releaseStart); gain.gain.exponentialRampToValueAtTime(0.0001, releaseStart + release); osc.stop(releaseStart + release + 0.02);节点是即用即弃的,每个音符 new 一套,播完就断开引用交给垃圾回收。我试过做节点池复用,实测下来收益不明显,反而因为状态清理不干净引入了偶发的音量残留问题,后来干脆放弃。一个和弦最多十几个振荡器同时存在,现代浏览器完全扛得住。
3.3 复音数量控制和削波保护
MIDI 曲子里经常出现几十个音符同时触发的情况,尤其是密集的钢琴段落加上踏板效果。如果不加限制,同时存在的振荡器数量会飙升,轻则 CPU 占用爆表、音频卡顿,重则因为总幅度叠加导致削波,输出一片"炸裂"的失真。
我的做法是加一个简单的复音上限,默认 32 个声部。超过时按"最旧优先"淘汰——维护一个正在发声的声部队列,新音符进来时如果队列已满,就取出队首的声部,让它快速释放(给一个很短的回调时间,比如 20 毫秒),再把新声部入队。这个策略在听感上比直接砍掉新音符要自然得多,因为被牺牲的往往是已经衰减得差不多的旧音。
削波保护则靠一个DynamicsCompressorNode挂在总线末梢,阈值设在 -6 dB 左右,压缩比 4:1,启动时间短一点(0.003 秒),释放时间长一点(0.25 秒)。这样即使突然出现一个力度很大的和弦,输出也不会劈。这里有个经验:压缩器不要放在每个音符的链路上,那样压缩行为会互相干扰,听起来很怪;只放总线上一台就够。
4. 调度器:为什么靠 setTimeout 一定会翻车
4.1 两套时钟的天然矛盾
做这个播放器踩得最深的一个坑,是时间调度。最初我图省事,直接用setTimeout按事件时间差一个个触发音符。小曲子听着还行,一旦曲子长一点、或者切到别的标签页再切回来,节奏就开始漂移,和弦弹得像散装鼓点。
原因在于,setTimeout和AudioContext.currentTime是两套完全独立的时钟。前者受 JavaScript 事件循环、主线程阻塞、浏览器节流策略影响,精度和稳定性都很差;后者是音频硬件驱动的高精度时钟,稳定但只能用来安排未来的动作,不能告诉你"现在该触发了"。想要两者同步,必须引入一个前瞻式调度器。
思路是:用一个相对"粗糙"的定时器(比如每 25 毫秒跑一次)作为心跳,每次心跳时,把"未来 100 毫秒内"应该发生的音频事件全部提前安排给 Web Audio 的时间轴。真正决定发声时刻的是AudioContext.currentTime + 偏移量,跟定时器的抖动无关。定时器哪怕晚跑了 10 毫秒,只要没超过前瞻窗口,音符依然会在精准的时刻响起。
4.2 前瞻调度器的实现细节
核心是两个参数:调度间隔和前瞻窗口。调度间隔我取 25 毫秒,前瞻窗口取 100 毫秒。这两个数的关系是"窗口必须明显大于间隔",否则一旦某次心跳迟到,就会漏掉事件。100 比 25 留了 4 倍余量,实测比较稳。窗口也不能太大,太大会导致拖动进度条或暂停时,已经排进音频时间轴的音符还在响,产生"拖尾"。
const LOOKAHEAD_MS = 25; const SCHEDULE_AHEAD = 0.1; // 秒 function schedulerTick() { while (nextEventIndex < events.length) { const ev = events[nextEventIndex]; const when = ev.time - startOffset + ctx.currentTime; if (when > ctx.currentTime + SCHEDULE_AHEAD) break; scheduleNote(ev, when); nextEventIndex++; } } timerId = setInterval(schedulerTick, LOOKAHEAD_MS);这里startOffset用来记录播放起点相对整首曲子开头的位置,用来支持从中间开始播放。每次开始播放时,我会把startOffset置为当前进度,nextEventIndex定位到第一个时间大于它的音符上。
4.3 暂停、变速和跳转怎么处理
暂停的处理有点反直觉:不能只是clearInterval,因为前瞻窗口里已经安排好的音符还会继续响。我的做法是暂停时把所有正在发声的声部快速释放,然后清掉定时器,记录当前进度。恢复时重新计算startOffset,从当前进度对应的索引继续。因为已经排进音频时间轴的音符会被强制释放,听感上不会有拖尾。
变速靠的是把事件时间乘以一个比例系数。加快时系数小于 1,所有音符的时间点整体前移。这里要小心,变速后前瞻窗口的判断依然以真实音频时间为准,所以计算when的时候必须先乘系数再判断,否则会提前触发一堆音符。我最初就是先在原时间轴上判断、再乘系数,结果 2 倍速时音符挤成一团。
进度条跳转则是重建整个调度状态:释放所有声部,清定时器,重新计算索引和偏移量。跳转的判定我用二分查找在事件数组里定位,因为数组本来就按时间排序,没必要线性扫。
提示:调度器心跳里不要做任何重活。解析、渲染、DOM 操作全部挪到别的地方,心跳函数里只做"取事件、算时间、排音符"三件事,这是保证节奏稳定的关键。
5. 可视化:把看不见的音乐画出来
5.1 视觉隐喻决定的渲染方案
做了好几个版本的视觉方案之后,我最终停在"瀑布流 + 钢琴卷帘"的组合上。横轴是时间,纵轴是音高(0 到 127,越高越靠上),每个音符画成一个矩形色块,宽度对应时值,颜色对应声部。播放时有一条竖向的游标线从左往右扫,游标右边是即将到来的音符,左边是已经过去的音符,音符块随着游标临近逐渐变亮。
这个视觉隐喻的好处是"音高、时值、声部"三个维度一眼可读。相比之下纯粹的频谱柱状图很炫但信息量低,看不出旋律走向;单纯的钢琴键盘光效好看但不显示未来。瀑布流把时间维度展开之后,整首曲子的结构——哪里是和弦堆叠、哪里是单声部旋律、哪里节奏密集——一眼就能判断出来。
有趣的是,这和那些数据库可视化工具的设计逻辑其实是共通的。像 Redis 可视化工具、Kafka 可视化工具、MySQL 客户端可视化工具,它们解决的都是同一类问题:把一个抽象的、结构化的数据模型翻译成人能快速扫读的图形。Redis 把键值对画成树,Kafka 把分区和消费位点画成进度,我的 MIDI 可视化把事件流画成时间轴上的矩形。核心思路都是"选对坐标轴,让数据自己说话"。
5.2 Canvas 分层与坐标映射
用 Canvas 而不是 SVG 或者 DOM 元素,是为数量级考虑。一首三分钟的曲子轻松有几万个音符事件,用 DOM 节点渲染必然会卡。Canvas 只维护一张位图,绘制成本跟元素数量基本线性,几万个矩形在现代机器上可以跑到 60 帧。
我把画布分成两层:底层是静态层,画网格、小节线、音高参考线、音符的暗色轮廓,这一层只在尺寸变化或曲子切换时重绘;顶层是动态层,每帧清空后画游标、当前高亮音符、频谱条。这样每帧的实际绘制量只有几百个图元,压力小很多。更进一步,静态层可以画到OffscreenCanvas上,主线程每帧直接drawImage贴过来,又省一笔。
坐标映射是另一个容易出错的地方。音高到纵坐标是线性的,y = (127 - pitch) / 127 * height。但时间到横坐标不能简单线性,因为整首曲子的时长可能变化很大,一根时间轴要么短曲子挤成一条线,要么长曲子稀疏得看不清。我加了缩放系数,允许用户滚轮缩放时间轴,同时做视口裁剪——只绘制可视时间范围内的事件,超出的直接跳过。
function timeToX(t, viewStart, viewDuration, width) { return ((t - viewStart) / viewDuration) * width; } function pitchToY(pitch, height) { return ((127 - pitch) / 127) * height; }5.3 频谱联动与帧率控制
除了音符块,我还挂了一个AnalyserNode在主总线上,取getByteFrequencyData拿频谱数据,在画布底部画一根根竖条。这个不是装饰——它能帮你在听感之外确认音色的能量分布,调试合成器的滤波参数时特别有用。比如你发现高频能量几乎为零,那说明低通截止频率设得太低了,音色闷。
帧率控制上,渲染循环用requestAnimationFrame,但绘制逻辑和音频时钟分开。每一帧我从AudioContext.currentTime反推当前播放位置,再决定游标画在哪里。这样即使帧率掉到 30,音画同步依然准确,只是游标移动看起来没那么顺滑。反过来如果是根据帧数累加时间,一旦掉帧音画就会逐渐错位,弹幕里最常见的"声音比画面快"就是这个原因。
AnalyserNode的参数设置也有讲究。fftSize决定频率分辨率,默认 2048 对应 1024 个频点;如果只是想画个氛围频谱,把smoothingTimeConstant调到 0.8 左右,柱状条变化会柔和很多,不会每帧乱跳。要更灵敏的响应就把它降到 0.6 以下,代价是画面会有点闪。
6. 踩过的坑和排查实录
6.1 常见问题速查表
下面这张表是我在实际调试和收集用户反馈过程中整理出来的,基本覆盖了这个项目九成以上的"症状—病因—处方"。
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| 浏览器里静音,控制台报 NotAllowedError | AudioContext 被自动播放策略挂起 | 在用户点击事件里调用ctx.resume(),并保证首次发声发生在交互之后 |
| 某些音符永远不结束 | note on 的 velocity 为 0 未转成 note off | 解析阶段统一归一化事件类型 |
| 长曲子后期节奏越来越飘 | 用 setTimeout 直接触发音符 | 改用前瞻式调度器,以音频时钟为准 |
| 和弦听起来刺耳失真 | 多声部叠加后总幅度过载 | 总线上加压缩器,同时限制复音数 |
| 曲子一开头速度就错 | 把 SMPTE division 当成 PPQ 处理 | 检测 division 最高位,分别走两套换算 |
| 长 delta time 全算错 | 可变长度量高低位顺序弄反 | 按"左移 7 位后或上新字节"的方式累积 |
| 可视化卡成幻灯片 | 用 DOM 或 SVG 逐音符渲染 | 换 Canvas,静态层和动态层分开绘制 |
| 跳转进度条后有一段旧音残留 | 前瞻窗口内已排程的声部未释放 | 跳转前强制释放所有活动声部并重建调度状态 |
| 切换标签页回来节奏错乱 | 后台标签页定时器被节流 | 恢复时重新对齐currentTime,丢弃过期事件而不是补播 |
| 音色又闷又糊 | 低通截止频率设得过低 | 让截止频率随音高上移,或用频谱图辅助调整 |
6.2 几个不那么容易发现的细节
第一,解析器要能容忍"脏文件"。市面上流传的 MIDI 文件很多是几十年前用各种工具生成的,轨道的MTrk长度字段经常和实际数据对不上,有的多几个字节的填充,有的缺少末尾的0x2F。我最初严格按长度字段走,结果遇到这类文件就直接解析崩掉,报一堆越界错误。后来改成"读到0x2F就认为该轨结束,长度字段只做参考",兼容性一下就上去了。
第二,AudioContext的创建时机很关键。有些人图省事在模块加载时就 new 一个,结果页面一打开浏览器就报警告,说上下文被挂起。正确姿势是在用户第一次点击播放时才创建,或者创建后立刻检查state,如果是suspended就在交互回调里resume()。这个细节看着小,但它决定了你的工具在某些浏览器上能不能出声。
第三,关于 tick 到秒的累积换算,绝对不要用浮点累加每个 delta 的乘积。几百个小浮点数加下来,误差会累积到肉眼可见的程度。我现在的做法是维护一个整数 tick 计数,要用秒的时候才用当前 tempo 一次性换算,这样误差只来自单次乘法,不会滚雪球。改完这个之后,同一首曲子从头听到尾,末端的音符位置和起始时算出来的完全一致。
第四,可视化里音符的横向裁剪我用了"按视口过滤事件"的策略,但过滤本身不能每帧重扫整个事件数组。我在缩放或拖动时先对事件数组做一次二分定位,得到视口内的索引区间,之后每帧只遍历这个区间。这个优化在几万事件的曲子上效果立竿见影,帧率从二十几直接回到六十。
第五,如果你的 MIDI 里有多个轨道的 tempo 事件互相冲突——这种文件真实存在,尤其是人工拼接出来的——我的处理是以第一条轨道(通常是元事件轨)的 tempo 为准,忽略其他轨道的 tempo。这不是规范强制,但听感上最稳定。我曾经把每条轨道的 tempo 都应用一遍,结果速度在几个值之间反复横跳,听起来像卡碟。
第六,想给可视化加一点"呼吸感"的话,可以让音符块的亮度跟随该时刻的力度值(velocity)变化,力度大的更亮、更饱和,力度小的偏灰。这个改动只有几行代码,但画面立刻活了,声部之间的强弱层次一眼就能分辨。我在实际对比之后发现,加了力度映射的版本,用户能更容易地判断出哪一句是主旋律、哪一句是伴奏。
最后分享一点我在这个项目里体会最深的东西:Web 端做音频,最大的敌人不是功能复杂,而是各种"看不见的时钟"和"没写在文档里的浏览器行为"。解析和合成都可以照着规范一步步来,唯独调度这一块,你必须亲自被坑过才能真正理解为什么前瞻窗口要留余量、为什么不能依赖定时器精度。我现在回看第一版代码里那段朴素的setTimeout循环,能清楚地看到自己当时对时间这件事的理解有多浅。